Add Pushover notification provider (#385)

This commit is contained in:
Flaminel
2025-12-13 21:24:34 +02:00
committed by GitHub
parent b343165644
commit b16fa70774
78 changed files with 9006 additions and 58 deletions

View File

@@ -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

View File

@@ -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<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

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

View File

@@ -4,5 +4,6 @@ public enum NotificationProviderType
{
Notifiarr,
Apprise,
Ntfy
Ntfy,
Pushover
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Enums;
public enum PushoverPriority
{
Lowest = -2,
Low = -1,
Normal = 0,
High = 1,
Emergency = 2
}

View File

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

View File

@@ -86,7 +86,7 @@ public class DownloadServiceFactoryTests : IDisposable
// BlocklistProvider requires specific constructor arguments
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
services.AddSingleton(new BlocklistProvider(
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
Mock.Of<ILogger<BlocklistProvider>>(),
scopeFactoryMock.Object,
_memoryCache));

View File

@@ -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<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
var unknownEventType = (NotificationEventType)999;
// Act & Assert
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => _service.GetProvidersForEventAsync(unknownEventType));
}
[Fact]
public async Task GetActiveProvidersAsync_DatabaseError_ReturnsEmptyListAndLogsError()
{
// Arrange - dispose context to simulate database error
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
var loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
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<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to load notification providers")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
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<NotificationConfig>().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<NotificationConfig>().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
}

View File

@@ -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<IAppriseProxy> _appriseProxyMock;
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -24,11 +26,13 @@ public class NotificationProviderFactoryTests
_appriseProxyMock = new Mock<IAppriseProxy>();
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
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<string>(),
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string>()
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<PushoverProvider>(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<string> { "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<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() })
};
foreach (var (type, configObj) in configs)

View File

@@ -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<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public PushoverProxyTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(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<PushoverException>(() =>
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<PushoverException>(() =>
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<PushoverException>(() =>
proxy.SendNotification(CreatePayload()));
Assert.Contains("Rate limit exceeded", ex.Message);
}
[Fact]
public async Task SendNotification_WhenApiReturnsStatus0_ThrowsPushoverException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"status\":0,\"errors\":[\"user key is invalid\"]}")
});
// Act & Assert
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
proxy.SendNotification(CreatePayload()));
Assert.Contains("Unable to connect to Pushover API", ex.Message);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(statusCode)
{
Content = new StringContent(responseBody)
});
}
#endregion
}

View File

@@ -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<IPushoverProxy> _proxyMock;
private readonly PushoverConfig _config;
private readonly PushoverProvider _provider;
public PushoverProviderTests()
{
_proxyMock = new Mock<IPushoverProxy>();
_config = new PushoverConfig
{
Id = Guid.NewGuid(),
ApiToken = "test-api-token",
UserKey = "test-user-key",
Devices = new List<string>(),
Priority = PushoverPriority.Normal,
Sound = "",
Retry = null,
Expire = null,
Tags = new List<string>()
};
_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<PushoverPayload>()))
.Callback<PushoverPayload>(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<PushoverPayload>()))
.Callback<PushoverPayload>(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<string>(),
Priority = PushoverPriority.High,
Sound = "",
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string>(),
Priority = PushoverPriority.Emergency,
Sound = "",
Retry = 60,
Expire = 3600,
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string>(),
Priority = PushoverPriority.High, // Not Emergency
Sound = "",
Retry = 60, // Should be ignored
Expire = 3600, // Should be ignored
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string> { "device1", "device2", "device3" },
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<PushoverPayload>()))
.Callback<PushoverPayload>(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<string>(),
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string> { "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<PushoverPayload>()))
.Callback<PushoverPayload>(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<string>(),
Priority = PushoverPriority.Normal,
Sound = "cosmic",
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string, string>()
};
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string, string>()
};
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string> { " device1 ", "device2 " },
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string> { "device1", "", " ", "device2" },
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string>()
};
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
var context = CreateTestContext();
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<PushoverPayload>()))
.ThrowsAsync(new PushoverException("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<PushoverException>(() => _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<string, string>()
};
PushoverPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
.Callback<PushoverPayload>(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<string, string>()
};
}
#endregion
}

View File

@@ -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,

View File

@@ -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

View File

@@ -68,7 +68,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
@@ -92,7 +92,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
@@ -116,7 +116,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
@@ -140,7 +140,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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")
};
}
}

View File

@@ -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<INtfyProxy>();
return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy);
}
private INotificationProvider CreatePushoverProvider(NotificationProviderDto config)
{
var pushoverConfig = (PushoverConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IPushoverProxy>();
return new PushoverProvider(config.Name, config.Type, pushoverConfig, proxy);
}
}

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover;
public interface IPushoverProxy
{
Task SendNotification(PushoverPayload payload);
}

View File

@@ -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)
{
}
}

View File

@@ -0,0 +1,54 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover;
public sealed record PushoverPayload
{
/// <summary>
/// Application API token (required)
/// </summary>
public string Token { get; init; } = string.Empty;
/// <summary>
/// User/group key (required)
/// </summary>
public string User { get; init; } = string.Empty;
/// <summary>
/// Message body (required, max 1024 chars)
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// Message title (optional, max 250 chars)
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Target devices (comma-separated)
/// </summary>
public string? Device { get; init; }
/// <summary>
/// Priority level (-2 to 2)
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Notification sound
/// </summary>
public string? Sound { get; init; }
/// <summary>
/// Retry interval for emergency priority (min 30 seconds)
/// </summary>
public int? Retry { get; init; }
/// <summary>
/// Expiration for emergency priority (max 10800 seconds)
/// </summary>
public int? Expire { get; init; }
/// <summary>
/// Tags for receipt tracking
/// </summary>
public string? Tags { get; init; }
}

View File

@@ -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<PushoverConfig>
{
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;
}
}

View File

@@ -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<string, string>
{
["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<PushoverResponse>(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);
}
}
}

View File

@@ -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<string>? Errors { get; init; }
public bool IsSuccess => Status == 1;
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Persistence\Cleanuparr.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -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<TestEnum> _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<ArgumentException>(() => _converter.ConvertFromProvider("nonexistent"));
}
[Fact]
public void ConvertFromProvider_WithEmptyString_ThrowsArgumentException()
{
Should.Throw<ArgumentException>(() => _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<TestFlagsEnum>();
var result = (string?)flagsConverter.ConvertToProvider(TestFlagsEnum.Flag1);
result.ShouldBe("flag1");
}
[Fact]
public void ConvertToProvider_WithFlagsEnum_ConvertsNone()
{
var flagsConverter = new LowercaseEnumConverter<TestFlagsEnum>();
var result = (string?)flagsConverter.ConvertToProvider(TestFlagsEnum.None);
result.ShouldBe("none");
}
[Fact]
public void ConvertFromProvider_WithFlagsEnum_ParsesSingleFlag()
{
var flagsConverter = new LowercaseEnumConverter<TestFlagsEnum>();
var result = (TestFlagsEnum?)flagsConverter.ConvertFromProvider("flag2");
result.ShouldBe(TestFlagsEnum.Flag2);
}
#endregion
}

View File

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

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Log rolling size cannot exceed 100 MB");
}
#endregion
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("The minimum value for failed imports max strikes must be 3");
}
#endregion
}

View File

@@ -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<System.ComponentModel.DataAnnotations.ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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;
/// <summary>
/// Tests for the abstract QueueRule base class validation logic.
/// Uses StallRule as a concrete implementation for testing.
/// </summary>
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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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"
}

View File

@@ -52,6 +52,8 @@ public class DataContext : DbContext
public DbSet<NtfyConfig> NtfyConfigs { get; set; }
public DbSet<PushoverConfig> PushoverConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
@@ -141,10 +143,29 @@ public class DataContext : DbContext
.WithOne(c => c.NotificationConfig)
.HasForeignKey<NtfyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.PushoverConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<PushoverConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});
// Configure PushoverConfig List<string> conversions
modelBuilder.Entity<PushoverConfig>(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<BlacklistSyncHistory>(entity =>
{

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddPushoverProvider : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "pushover_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
api_token = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
user_key = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
devices = table.Column<string>(type: "TEXT", nullable: false),
priority = table.Column<string>(type: "TEXT", nullable: false),
sound = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
retry = table.Column<int>(type: "INTEGER", nullable: true),
expire = table.Column<int>(type: "INTEGER", nullable: true),
tags = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "pushover_configs");
}
}
}

View File

@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiToken")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("api_token");
b.Property<string>("Devices")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("devices");
b.Property<int?>("Expire")
.HasColumnType("INTEGER")
.HasColumnName("expire");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("priority");
b.Property<int?>("Retry")
.HasColumnType("INTEGER")
.HasColumnName("retry");
b.Property<string>("Sound")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("sound");
b.Property<string>("Tags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("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<Guid>("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 =>

View File

@@ -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]

View File

@@ -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!;
/// <summary>
/// Application API token (30 characters, [A-Za-z0-9])
/// </summary>
[Required]
[MaxLength(50)]
public string ApiToken { get; init; } = string.Empty;
/// <summary>
/// User/group key (30 characters, [A-Za-z0-9])
/// </summary>
[Required]
[MaxLength(50)]
public string UserKey { get; init; } = string.Empty;
/// <summary>
/// Target specific devices (comma-separated when sent to API)
/// </summary>
public List<string> Devices { get; init; } = [];
/// <summary>
/// Notification priority (-2 to 2)
/// </summary>
[Required]
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
/// <summary>
/// Notification sound (built-in or custom)
/// </summary>
[MaxLength(50)]
public string? Sound { get; init; }
/// <summary>
/// Retry interval in seconds for emergency priority (min 30)
/// </summary>
public int? Retry { get; init; }
/// <summary>
/// Expiration time in seconds for emergency priority (max 10800)
/// </summary>
public int? Expire { get; init; }
/// <summary>
/// Tags for receipt tracking and batch cancellation
/// </summary>
public List<string> 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");
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Exclude>[*]*.Migrations.*</Exclude>
<ExcludeByFile>**/Migrations/**/*.cs</ExcludeByFile>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M428.7 67C324.3-28.4 162.4-21.1 67 83.3S-21.1 349.6 83.3 445s266.3 88.1 361.7-16.3S533.1 162.4 428.7 67m-43 115.7c-3.1 13.2-8.9 26.6-17.5 39.9-8.6 13.4-19.4 25.5-32.3 36.3-13 10.8-27.8 19.6-44.6 26.4s-34.6 10.1-53.4 10.1h-2.1L182 415.9h-60.8l119.6-268.7 64.2-8.5-62.5 141.1c11-.8 21.8-4.6 32.3-11.2 10.6-6.6 20.3-14.9 29.2-24.9s16.5-21.1 23-33.4 11.1-24.3 13.9-36.1c1.7-7.3 2.5-14.4 2.3-21.1-.1-6.8-1.9-12.7-5.3-17.7s-8.5-9.2-15.4-12.3-16.3-4.6-28.1-4.6c-13.8 0-27.4 2.3-40.8 6.8s-25.8 11.1-37.2 19.7-21.3 19.3-29.8 32.1-14.5 27.4-18.2 43.7c-1.4 5.4-2.3 9.6-2.5 12.9-.3 3.2-.4 5.9-.2 8 .1 2.1.4 3.7.8 4.9.4 1.1.8 2.3 1.1 3.4q-21.6 0-31.5-8.7c-6.6-5.8-8.2-15.8-4.9-30.2 3.4-14.9 11.1-29.2 23-42.7 12-13.5 26.2-25.4 42.7-35.7s34.5-18.4 54.1-24.5 38.7-9.1 57.3-9.1c16.3 0 30.1 2.3 41.2 7s19.8 10.8 26 18.4 10.1 16.5 11.6 26.6c1.6 10 1.1 20.6-1.4 31.6" style="fill-rule:evenodd;clip-rule:evenodd;fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1003 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill-rule:evenodd;clip-rule:evenodd;fill:#249df1"/><path d="m240.7 147.2 64.2-8.5-62.5 141.1c11-.8 21.8-4.6 32.3-11.2 10.6-6.6 20.3-14.9 29.2-24.9s16.5-21.1 23-33.4 11.1-24.3 13.9-36.1c1.7-7.3 2.5-14.4 2.3-21.1-.1-6.8-1.9-12.7-5.3-17.7-3.4-5.1-8.5-9.2-15.4-12.3s-16.3-4.6-28.1-4.6c-13.8 0-27.4 2.3-40.8 6.8s-25.8 11.1-37.2 19.7-21.3 19.3-29.8 32.1-14.5 27.4-18.2 43.7-1.4 5.4-2.3 9.6-2.5 12.9-.3 3.2-.4 5.9-.2 8 .1 2.1.4 3.7.8 4.9.4 1.1.8 2.3 1.1 3.4q-21.6 0-31.5-8.7c-6.6-5.8-8.2-15.8-4.9-30.2 3.4-14.9 11.1-29.2 23-42.7 12-13.5 26.2-25.4 42.7-35.7s34.5-18.4 54.1-24.5 38.7-9.1 57.3-9.1c16.3 0 30.1 2.3 41.2 7s19.8 10.8 26 18.4 10.1 16.5 11.6 26.6c1.6 10.1 1.1 20.7-1.5 31.7-3.1 13.2-8.9 26.6-17.5 39.9-8.6 13.4-19.4 25.5-32.3 36.3-13 10.8-27.8 19.6-44.6 26.4s-34.6 10.1-53.4 10.1h-2.1L182 415.9h-60.8z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@@ -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) {}

View File

@@ -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<NotificationProviderDto>(`${this.baseUrl}/ntfy`, provider);
}
/**
* Create a new Pushover provider
*/
createPushoverProvider(provider: CreatePushoverProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/pushover`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -183,6 +240,13 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/ntfy/${id}`, provider);
}
/**
* Update an existing Pushover provider
*/
updatePushoverProvider(id: string, provider: UpdatePushoverProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/pushover/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -211,17 +275,26 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/ntfy/test`, testRequest);
}
/**
* Test a Pushover provider (without ID - for testing configuration before saving)
*/
testPushoverProvider(testRequest: TestPushoverProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/pushover/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
createProvider(provider: any, type: NotificationProviderType): Observable<NotificationProviderDto> {
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<NotificationProviderDto> {
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<TestNotificationResult> {
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}`);
}

View File

@@ -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/'
}
];

View File

@@ -0,0 +1,203 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Pushover Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)"
>
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- API Token -->
<div class="field">
<label for="api-token">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.apiToken')"
></i>
API Token *
</label>
<input
id="api-token"
type="password"
pInputText
[formControl]="apiTokenControl"
placeholder="Enter your Pushover API token"
class="w-full"
/>
<small *ngIf="hasFieldError(apiTokenControl, 'required')" class="form-error-text">API token is required</small>
<small class="form-helper-text">Your application API token from Pushover. Create one at pushover.net/apps/build.</small>
</div>
<!-- User Key -->
<div class="field">
<label for="user-key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.userKey')"
></i>
User Key *
</label>
<input
id="user-key"
type="password"
pInputText
[formControl]="userKeyControl"
placeholder="Enter your Pushover user key"
class="w-full"
/>
<small *ngIf="hasFieldError(userKeyControl, 'required')" class="form-error-text">User key is required</small>
<small class="form-helper-text">Your user/group key from your Pushover dashboard.</small>
</div>
<!-- Devices (Optional) -->
<div class="field">
<label for="devices">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.devices')"
></i>
Devices (Optional)
</label>
<app-mobile-autocomplete
[formControl]="devicesControl"
placeholder="Add device name and press Enter"
></app-mobile-autocomplete>
<small class="form-helper-text">Leave empty to send to all devices, or enter specific device names.</small>
</div>
<!-- Priority -->
<div class="field">
<label for="priority">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.priority')"
></i>
Priority *
</label>
<p-select
id="priority"
[formControl]="priorityControl"
[options]="priorityOptions"
optionLabel="label"
optionValue="value"
class="w-full"
[showClear]="false"
></p-select>
<small *ngIf="hasFieldError(priorityControl, 'required')" class="form-error-text">Priority is required</small>
<small class="form-helper-text">The priority level for notifications. Emergency priority will repeat until acknowledged.</small>
</div>
<!-- Retry (conditional - Emergency priority only) -->
<div class="field" *ngIf="currentPriority === PushoverPriority.Emergency">
<label for="retry">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.retry')"
></i>
Retry (seconds) *
</label>
<p-inputNumber
id="retry"
[formControl]="retryControl"
[min]="30"
[showButtons]="true"
[step]="30"
placeholder="30"
class="w-full"
inputStyleClass="w-full"
></p-inputNumber>
<small *ngIf="hasFieldError(retryControl, 'required')" class="form-error-text">Retry is required for emergency priority</small>
<small *ngIf="hasFieldError(retryControl, 'min')" class="form-error-text">Minimum retry is 30 seconds</small>
<small class="form-helper-text">How often (in seconds) the notification will be resent until acknowledged. Minimum 30 seconds.</small>
</div>
<!-- Expire (conditional - Emergency priority only) -->
<div class="field" *ngIf="currentPriority === PushoverPriority.Emergency">
<label for="expire">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.expire')"
></i>
Expire (seconds) *
</label>
<p-inputNumber
id="expire"
[formControl]="expireControl"
[min]="1"
[max]="10800"
[showButtons]="true"
[step]="60"
placeholder="3600"
class="w-full"
inputStyleClass="w-full"
></p-inputNumber>
<small *ngIf="hasFieldError(expireControl, 'required')" class="form-error-text">Expire is required for emergency priority</small>
<small *ngIf="hasFieldError(expireControl, 'min')" class="form-error-text">Expire must be at least 1 second</small>
<small *ngIf="hasFieldError(expireControl, 'max')" class="form-error-text">Expire cannot exceed 10800 seconds (3 hours)</small>
<small class="form-helper-text">How long (in seconds) the notification will continue to be retried. Maximum 10800 seconds (3 hours).</small>
</div>
<!-- Sound -->
<div class="field">
<label for="sound">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.sound')"
></i>
Sound (Optional)
</label>
<p-select
id="sound"
[formControl]="soundControl"
[options]="soundOptions"
optionLabel="label"
optionValue="value"
class="w-full"
[showClear]="false"
></p-select>
<small class="form-helper-text">Choose a notification sound, or select Custom to enter your own.</small>
</div>
<!-- Custom Sound (conditional) -->
<div class="field" *ngIf="isCustomSound">
<label for="custom-sound">Custom Sound Name *</label>
<input
id="custom-sound"
type="text"
pInputText
[formControl]="customSoundControl"
placeholder="Enter custom sound name"
class="w-full"
/>
<small *ngIf="hasFieldError(customSoundControl, 'required')" class="form-error-text">Custom sound name is required</small>
<small class="form-helper-text">Enter the name of a custom sound you've uploaded to Pushover.</small>
</div>
<!-- Tags (Optional) -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('pushover.tags')"
></i>
Tags (Optional)
</label>
<app-mobile-autocomplete
[formControl]="tagsControl"
placeholder="Add tag and press Enter"
></app-mobile-autocomplete>
<small class="form-helper-text">Tags for receipt tracking and batch cancellation of emergency notifications.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -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<PushoverFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<PushoverFormData>();
// Provider-specific form controls
apiTokenControl = new FormControl('', [Validators.required]);
userKeyControl = new FormControl('', [Validators.required]);
devicesControl = new FormControl<string[]>([]);
priorityControl = new FormControl(PushoverPriority.Normal, [Validators.required]);
soundControl = new FormControl('');
customSoundControl = new FormControl('');
retryControl = new FormControl<number | null>(null);
expireControl = new FormControl<number | null>(null);
tagsControl = new FormControl<string[]>([]);
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;
}
}

View File

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

View File

@@ -187,6 +187,17 @@
(test)="onNtfyTest($event)"
></app-ntfy-provider>
<!-- Pushover Provider Modal -->
<app-pushover-provider
[visible]="showPushoverModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onPushoverSave($event)"
(cancel)="onProviderCancel()"
(test)="onPushoverTest($event)"
></app-pushover-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -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
*/

View File

@@ -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 {

View File

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

View File

@@ -14,4 +14,5 @@ export enum NotificationProviderType {
Notifiarr = "Notifiarr",
Apprise = "Apprise",
Ntfy = "Ntfy",
Pushover = "Pushover",
}

View File

@@ -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[];
}

View File

@@ -0,0 +1,7 @@
export enum PushoverPriority {
Lowest = 'Lowest',
Low = 'Low',
Normal = 'Normal',
High = 'High',
Emergency = 'Emergency'
}

View File

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

11
codecov.yml Normal file
View File

@@ -0,0 +1,11 @@
coverage:
status:
project:
default:
target: auto
patch:
default:
target: auto
ignore:
- "**/Migrations/**"

View File

@@ -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.
<ElementNavigator />
<div className={styles.documentationPage}>
<div className={styles.section}>
<SectionTitle icon="🔔">Configuration</SectionTitle>
<p className={styles.sectionDescription}>
Configure Pushover to send push notifications to your devices.
</p>
<ConfigSection
title="API Token"
icon="🔑"
id="pushover.apiToken"
>
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.
</ConfigSection>
<ConfigSection
title="User Key"
icon="👤"
id="pushover.userKey"
>
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)
</ConfigSection>
<ConfigSection
title="Devices"
icon="📱"
id="pushover.devices"
>
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.
</ConfigSection>
<ConfigSection
title="Priority"
icon="🔥"
id="pushover.priority"
>
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)
</ConfigSection>
<ConfigSection
title="Retry"
icon="🔄"
id="pushover.retry"
>
**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.
</ConfigSection>
<ConfigSection
title="Expire"
icon="⏰"
id="pushover.expire"
>
**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.
</ConfigSection>
<ConfigSection
title="Sound"
icon="🔊"
id="pushover.sound"
>
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)
</ConfigSection>
<ConfigSection
title="Tags"
icon="🏷️"
id="pushover.tags"
>
Tags for receipt tracking and batch cancellation of emergency notifications.
Tags are useful when you want to cancel pending emergency notifications programmatically.
</ConfigSection>
</div>
</div>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M428.7 67C324.3-28.4 162.4-21.1 67 83.3S-21.1 349.6 83.3 445s266.3 88.1 361.7-16.3S533.1 162.4 428.7 67m-43 115.7c-3.1 13.2-8.9 26.6-17.5 39.9-8.6 13.4-19.4 25.5-32.3 36.3-13 10.8-27.8 19.6-44.6 26.4s-34.6 10.1-53.4 10.1h-2.1L182 415.9h-60.8l119.6-268.7 64.2-8.5-62.5 141.1c11-.8 21.8-4.6 32.3-11.2 10.6-6.6 20.3-14.9 29.2-24.9s16.5-21.1 23-33.4 11.1-24.3 13.9-36.1c1.7-7.3 2.5-14.4 2.3-21.1-.1-6.8-1.9-12.7-5.3-17.7s-8.5-9.2-15.4-12.3-16.3-4.6-28.1-4.6c-13.8 0-27.4 2.3-40.8 6.8s-25.8 11.1-37.2 19.7-21.3 19.3-29.8 32.1-14.5 27.4-18.2 43.7c-1.4 5.4-2.3 9.6-2.5 12.9-.3 3.2-.4 5.9-.2 8 .1 2.1.4 3.7.8 4.9.4 1.1.8 2.3 1.1 3.4q-21.6 0-31.5-8.7c-6.6-5.8-8.2-15.8-4.9-30.2 3.4-14.9 11.1-29.2 23-42.7 12-13.5 26.2-25.4 42.7-35.7s34.5-18.4 54.1-24.5 38.7-9.1 57.3-9.1c16.3 0 30.1 2.3 41.2 7s19.8 10.8 26 18.4 10.1 16.5 11.6 26.6c1.6 10 1.1 20.6-1.4 31.6" style="fill-rule:evenodd;clip-rule:evenodd;fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1003 B

1
docs/static/img/icons/pushover.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill-rule:evenodd;clip-rule:evenodd;fill:#249df1"/><path d="m240.7 147.2 64.2-8.5-62.5 141.1c11-.8 21.8-4.6 32.3-11.2 10.6-6.6 20.3-14.9 29.2-24.9s16.5-21.1 23-33.4 11.1-24.3 13.9-36.1c1.7-7.3 2.5-14.4 2.3-21.1-.1-6.8-1.9-12.7-5.3-17.7-3.4-5.1-8.5-9.2-15.4-12.3s-16.3-4.6-28.1-4.6c-13.8 0-27.4 2.3-40.8 6.8s-25.8 11.1-37.2 19.7-21.3 19.3-29.8 32.1-14.5 27.4-18.2 43.7-1.4 5.4-2.3 9.6-2.5 12.9-.3 3.2-.4 5.9-.2 8 .1 2.1.4 3.7.8 4.9.4 1.1.8 2.3 1.1 3.4q-21.6 0-31.5-8.7c-6.6-5.8-8.2-15.8-4.9-30.2 3.4-14.9 11.1-29.2 23-42.7 12-13.5 26.2-25.4 42.7-35.7s34.5-18.4 54.1-24.5 38.7-9.1 57.3-9.1c16.3 0 30.1 2.3 41.2 7s19.8 10.8 26 18.4 10.1 16.5 11.6 26.6c1.6 10.1 1.1 20.7-1.5 31.7-3.1 13.2-8.9 26.6-17.5 39.9-8.6 13.4-19.4 25.5-32.3 36.3-13 10.8-27.8 19.6-44.6 26.4s-34.6 10.1-53.4 10.1h-2.1L182 415.9h-60.8z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 955 B