mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-23 22:18:39 -05:00
Add Pushover notification provider (#385)
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
Ntfy,
|
||||
Pushover
|
||||
}
|
||||
|
||||
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum PushoverPriority
|
||||
{
|
||||
Lowest = -2,
|
||||
Low = -1,
|
||||
Normal = 0,
|
||||
High = 1,
|
||||
Emergency = 2
|
||||
}
|
||||
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal file
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal 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
|
||||
];
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
|
||||
public interface IPushoverProxy
|
||||
{
|
||||
Task SendNotification(PushoverPayload payload);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
1091
code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.Designer.cs
generated
Normal file
1091
code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
13
code/backend/coverage.runsettings
Normal file
13
code/backend/coverage.runsettings
Normal 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>
|
||||
1
code/frontend/public/icons/ext/pushover-light.svg
Normal file
1
code/frontend/public/icons/ext/pushover-light.svg
Normal 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
code/frontend/public/icons/ext/pushover.svg
Normal file
1
code/frontend/public/icons/ext/pushover.svg
Normal 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 |
@@ -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) {}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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/'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../../styles/settings-shared.scss';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,4 +14,5 @@ export enum NotificationProviderType {
|
||||
Notifiarr = "Notifiarr",
|
||||
Apprise = "Apprise",
|
||||
Ntfy = "Ntfy",
|
||||
Pushover = "Pushover",
|
||||
}
|
||||
14
code/frontend/src/app/shared/models/pushover-config.model.ts
Normal file
14
code/frontend/src/app/shared/models/pushover-config.model.ts
Normal 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[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum PushoverPriority {
|
||||
Lowest = 'Lowest',
|
||||
Low = 'Low',
|
||||
Normal = 'Normal',
|
||||
High = 'High',
|
||||
Emergency = 'Emergency'
|
||||
}
|
||||
25
code/frontend/src/app/shared/models/pushover-sounds.ts
Normal file
25
code/frontend/src/app/shared/models/pushover-sounds.ts
Normal 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
11
codecov.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
|
||||
ignore:
|
||||
- "**/Migrations/**"
|
||||
139
docs/docs/configuration/notifications/pushover.mdx
Normal file
139
docs/docs/configuration/notifications/pushover.mdx
Normal 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>
|
||||
1
docs/static/img/icons/pushover-light.svg
vendored
Normal file
1
docs/static/img/icons/pushover-light.svg
vendored
Normal 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
1
docs/static/img/icons/pushover.svg
vendored
Normal 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 |
Reference in New Issue
Block a user