Files
Cleanuparr/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs
T

391 lines
15 KiB
C#

using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.DryRun;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
public class EventCleanupServiceTests : IDisposable
{
private readonly ILogger<EventCleanupService> _logger;
private readonly ServiceCollection _services;
private readonly IServiceProvider _serviceProvider;
private readonly string _dbName;
public EventCleanupServiceTests()
{
_logger = Substitute.For<ILogger<EventCleanupService>>();
_services = new ServiceCollection();
_dbName = Guid.NewGuid().ToString();
// Setup in-memory database for testing
_services.AddDbContext<EventsContext>(options =>
options.UseInMemoryDatabase(databaseName: _dbName));
_serviceProvider = _services.BuildServiceProvider();
}
public void Dispose()
{
// Cleanup the in-memory database
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Database.EnsureDeleted();
}
[Fact]
public async Task ExecuteAsync_LogsStartMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity());
var cts = new CancellationTokenSource();
// Act - start and immediately cancel
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
await Task.Delay(200); // Give it time to process
await service.StopAsync(CancellationToken.None);
// Assert
_logger.HasLogContaining(LogLevel.Information, "started").ShouldBeTrue();
}
[Fact]
public async Task StopAsync_LogsStopMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity());
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(50);
await service.StartAsync(cts.Token);
await Task.Delay(100);
await service.StopAsync(CancellationToken.None);
// Assert
_logger.HasLogContaining(LogLevel.Information, "stopping").ShouldBeTrue();
}
[Fact]
public void Constructor_InitializesWithCorrectParameters()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
// Act
EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity());
// Assert - service should be created without exception
service.ShouldNotBeNull();
}
[Fact]
public async Task ExecuteAsync_GracefullyHandlesCancellation()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity());
var cts = new CancellationTokenSource();
// Act - cancel immediately
cts.Cancel();
// Start should not throw
await service.StartAsync(cts.Token);
await Task.Delay(50);
await service.StopAsync(CancellationToken.None);
// Assert - should have logged stopped message
_logger.HasLogContaining(LogLevel.Information, "stopped").ShouldBeTrue();
}
[Fact]
public async Task PerformCleanupAsync_DryRunOff_PurgesDryRunData()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), TimeProvider.System, new DryRunActivity());
// Act
await service.PerformCleanupAsync();
// Assert
await dryRunPurger.Received(1).PurgeAsync();
}
[Fact]
public async Task PerformCleanupAsync_DryRunOn_DoesNotPurge()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), TimeProvider.System, new DryRunActivity());
// Act
await service.PerformCleanupAsync();
// Assert
await dryRunPurger.DidNotReceive().PurgeAsync();
}
[Fact]
public async Task PerformCleanupAsync_PurgeThrows_ReturnsTrue()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
dryRunPurger.PurgeAsync().Returns(Task.FromException(new InvalidOperationException("boom")));
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), TimeProvider.System, new DryRunActivity());
// Act
bool purgeFailed = await service.PerformCleanupAsync();
// Assert
purgeFailed.ShouldBeTrue();
}
[Fact]
public async Task PerformCleanupAsync_PurgeSucceeds_ReturnsFalse()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), TimeProvider.System, new DryRunActivity());
// Act
bool purgeFailed = await service.PerformCleanupAsync();
// Assert
purgeFailed.ShouldBeFalse();
}
[Theory]
[InlineData(0, 30)]
[InlineData(1, 30)]
[InlineData(2, 60)]
[InlineData(3, 300)]
[InlineData(10, 300)]
public void GetRetryDelay_ReturnsExpectedDelay(int failures, int expectedSeconds)
{
// Act
TimeSpan delay = EventCleanupService.GetRetryDelay(failures);
// Assert
delay.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
}
[Fact]
public async Task PruneSeekerCyclesAsync_DeletesCompletedCyclesOlderThanRetention()
{
// Arrange
using EventsContext eventsContext = TestDataContextFactory.CreateEvents();
FakeTimeProvider timeProvider = new();
EventCleanupService service = new EventCleanupService(_logger, _serviceProvider.GetRequiredService<IServiceScopeFactory>(), timeProvider, new DryRunActivity());
Guid oldCompletedId = Guid.NewGuid();
eventsContext.SeekerCycles.AddRange(
new SeekerCycle
{
Id = oldCompletedId,
StartedAt = timeProvider.GetUtcNow().AddDays(-40),
CompletedAt = timeProvider.GetUtcNow().AddDays(-31),
CompletionReason = SeekerCycleCompletionReason.Completed,
},
new SeekerCycle
{
Id = Guid.NewGuid(),
StartedAt = timeProvider.GetUtcNow().AddDays(-1),
CompletedAt = null,
});
await eventsContext.SaveChangesAsync();
// Act
await service.PruneSeekerCyclesAsync(eventsContext, retentionDays: 30);
// Assert
(await eventsContext.SeekerCycles.CountAsync(c => c.Id == oldCompletedId)).ShouldBe(0);
}
[Fact]
public async Task PruneSeekerCyclesAsync_KeepsNewestCycleEvenWhenOld()
{
// Arrange
using EventsContext eventsContext = TestDataContextFactory.CreateEvents();
FakeTimeProvider timeProvider = new();
EventCleanupService service = new EventCleanupService(_logger, _serviceProvider.GetRequiredService<IServiceScopeFactory>(), timeProvider, new DryRunActivity());
Guid arrInstanceId = Guid.NewGuid();
Guid olderId = Guid.NewGuid();
Guid newestId = Guid.NewGuid();
eventsContext.SeekerCycles.AddRange(
new SeekerCycle
{
Id = olderId,
ArrInstanceId = arrInstanceId,
StartedAt = timeProvider.GetUtcNow().AddDays(-50),
CompletedAt = timeProvider.GetUtcNow().AddDays(-45),
CompletionReason = SeekerCycleCompletionReason.Completed,
},
new SeekerCycle
{
Id = newestId,
ArrInstanceId = arrInstanceId,
StartedAt = timeProvider.GetUtcNow().AddDays(-40),
CompletedAt = timeProvider.GetUtcNow().AddDays(-35),
CompletionReason = SeekerCycleCompletionReason.Completed,
});
await eventsContext.SaveChangesAsync();
// Act
await service.PruneSeekerCyclesAsync(eventsContext, retentionDays: 30);
// Assert
(await eventsContext.SeekerCycles.CountAsync(c => c.Id == olderId)).ShouldBe(0);
(await eventsContext.SeekerCycles.CountAsync(c => c.Id == newestId)).ShouldBe(1);
}
[Fact]
public async Task PruneSeekerCyclesAsync_KeepsOpenAndRecentCycles()
{
// Arrange
using EventsContext eventsContext = TestDataContextFactory.CreateEvents();
FakeTimeProvider timeProvider = new();
EventCleanupService service = new EventCleanupService(_logger, _serviceProvider.GetRequiredService<IServiceScopeFactory>(), timeProvider, new DryRunActivity());
Guid openCycleId = Guid.NewGuid();
Guid recentCompletedId = Guid.NewGuid();
eventsContext.SeekerCycles.AddRange(
new SeekerCycle
{
Id = openCycleId,
StartedAt = timeProvider.GetUtcNow().AddDays(-60),
CompletedAt = null,
},
new SeekerCycle
{
Id = recentCompletedId,
StartedAt = timeProvider.GetUtcNow().AddDays(-5),
CompletedAt = timeProvider.GetUtcNow().AddDays(-1),
CompletionReason = SeekerCycleCompletionReason.Completed,
});
await eventsContext.SaveChangesAsync();
// Act
await service.PruneSeekerCyclesAsync(eventsContext, retentionDays: 30);
// Assert
(await eventsContext.SeekerCycles.CountAsync()).ShouldBe(2);
}
[Fact]
public async Task WaitForNextRunAsync_DelayElapses_Completes()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
FakeTimeProvider timeProvider = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), timeProvider, new DryRunActivity());
// Act
Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None);
await Task.Yield();
bool completedBeforeAdvance = waitTask.IsCompleted;
timeProvider.Advance(TimeSpan.FromMinutes(5));
await waitTask;
// Assert
completedBeforeAdvance.ShouldBeFalse();
waitTask.IsCompleted.ShouldBeTrue();
}
[Fact]
public async Task WaitForNextRunAsync_SignalWithDryRunOff_CompletesImmediately()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
FakeTimeProvider timeProvider = new();
DryRunActivity dryRunActivity = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), timeProvider, dryRunActivity);
// Act
Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None);
dryRunActivity.RequestPurge();
await waitTask;
// Assert
waitTask.IsCompleted.ShouldBeTrue();
}
[Fact]
public async Task WaitForNextRunAsync_SignalWithDryRunOn_StaysPendingUntilDelayElapses()
{
// Arrange
IDryRunPurger dryRunPurger = Substitute.For<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger);
FakeTimeProvider timeProvider = new();
DryRunActivity dryRunActivity = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), timeProvider, dryRunActivity);
// Act
Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None);
dryRunActivity.RequestPurge();
// give the signal branch a chance to run and re-read the config
for (int i = 0; i < 10 && !waitTask.IsCompleted; i++)
{
await Task.Yield();
}
bool completedAfterSignal = waitTask.IsCompleted;
timeProvider.Advance(TimeSpan.FromMinutes(5));
await waitTask;
// Assert
completedAfterSignal.ShouldBeFalse();
waitTask.IsCompleted.ShouldBeTrue();
}
private IServiceProvider BuildProviderWithGeneralConfig(bool dryRunOn, IDryRunPurger dryRunPurger)
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
_services.AddDbContext<DataContext>(options => options.UseSqlite(connection));
_services.AddSingleton(dryRunPurger);
IServiceProvider provider = _services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();
DataContext dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
dataContext.Database.EnsureCreated();
dataContext.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = dryRunOn,
IgnoredDownloads = [],
Log = new LoggingConfig(),
});
dataContext.SaveChanges();
return provider;
}
}