mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-14 14:27:24 -04:00
261 lines
10 KiB
C#
261 lines
10 KiB
C#
using Cleanuparr.Domain.Enums;
|
|
using Cleanuparr.Infrastructure.Events;
|
|
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
|
using Cleanuparr.Persistence;
|
|
using Cleanuparr.Persistence.Models.Events;
|
|
using Cleanuparr.Persistence.Models.State;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Exercises the EventCleanupService prune logic against a real SQLite context
|
|
/// (the InMemory provider cannot run ExecuteDeleteAsync).
|
|
/// </summary>
|
|
public class EventCleanupLogicTests : IDisposable
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private readonly EventsContext _context;
|
|
private readonly DataContext _dataContext;
|
|
private readonly ServiceProvider _serviceProvider;
|
|
private readonly EventCleanupService _service;
|
|
|
|
public EventCleanupLogicTests()
|
|
{
|
|
_context = TestEventsContextFactory.Create();
|
|
_dataContext = TestDataContextFactory.Create();
|
|
|
|
ServiceCollection services = new();
|
|
services.AddSingleton(_context);
|
|
services.AddSingleton(_dataContext);
|
|
_serviceProvider = services.BuildServiceProvider();
|
|
|
|
_service = new EventCleanupService(
|
|
Substitute.For<ILogger<EventCleanupService>>(),
|
|
_serviceProvider.GetRequiredService<IServiceScopeFactory>(),
|
|
new FakeTimeProvider(Now));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_serviceProvider.Dispose();
|
|
_dataContext.Dispose();
|
|
_context.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PruneEventsAsync_DeletesEventsBeyondRetention()
|
|
{
|
|
_context.Events.Add(new AppEvent
|
|
{
|
|
EventType = EventType.StrikeReset,
|
|
Message = "stale",
|
|
Severity = EventSeverity.Information,
|
|
Timestamp = Now.AddDays(-400),
|
|
});
|
|
_context.Events.Add(new AppEvent
|
|
{
|
|
EventType = EventType.StrikeReset,
|
|
Message = "fresh",
|
|
Severity = EventSeverity.Information,
|
|
Timestamp = Now.AddDays(-10),
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
await _service.PruneEventsAsync(_context, retentionDays: 365);
|
|
|
|
List<AppEvent> remaining = await _context.Events.ToListAsync();
|
|
remaining.Count.ShouldBe(1);
|
|
remaining[0].Message.ShouldBe("fresh");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteResolvedManualEventsAsync_KeepsRecentlyResolvedOldEvents()
|
|
{
|
|
DateTimeOffset cutoff = Now.AddDays(-30);
|
|
|
|
// Created long ago but resolved just now — must survive so the publish cooldown still sees it.
|
|
ManualEvent freshlyResolved = new()
|
|
{
|
|
Type = ManualEventType.RecurringDownload,
|
|
Message = "fresh",
|
|
Severity = EventSeverity.Warning,
|
|
Timestamp = Now.AddDays(-40),
|
|
IsResolved = true,
|
|
ResolvedAt = Now,
|
|
};
|
|
// Created and resolved long ago — safe to delete.
|
|
ManualEvent longResolved = new()
|
|
{
|
|
Type = ManualEventType.SearchNotTriggered,
|
|
Message = "stale",
|
|
Severity = EventSeverity.Warning,
|
|
Timestamp = Now.AddDays(-40),
|
|
IsResolved = true,
|
|
ResolvedAt = Now.AddDays(-35),
|
|
};
|
|
// Old but still unresolved — never deleted here.
|
|
ManualEvent unresolved = new()
|
|
{
|
|
Type = ManualEventType.RecurringDownload,
|
|
Message = "open",
|
|
Severity = EventSeverity.Warning,
|
|
Timestamp = Now.AddDays(-40),
|
|
IsResolved = false,
|
|
};
|
|
_context.ManualEvents.AddRange(freshlyResolved, longResolved, unresolved);
|
|
await _context.SaveChangesAsync();
|
|
|
|
await _service.DeleteResolvedManualEventsAsync(_context, cutoff);
|
|
|
|
List<string> remaining = await _context.ManualEvents.Select(e => e.Message).ToListAsync();
|
|
remaining.ShouldContain("fresh");
|
|
remaining.ShouldContain("open");
|
|
remaining.ShouldNotContain("stale");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PruneJobRunsAsync_DeletesOnlyOldCompletedUnreferencedRuns()
|
|
{
|
|
DateTimeOffset oldTime = Now.AddDays(-40);
|
|
DateTimeOffset recentTime = Now.AddDays(-5);
|
|
|
|
JobRun unreferenced = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
|
|
JobRun referencedByStrike = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
|
|
JobRun referencedByEvent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
|
|
JobRun referencedByManualEvent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
|
|
JobRun recent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = recentTime, CompletedAt = recentTime };
|
|
JobRun incomplete = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = null };
|
|
_context.JobRuns.AddRange(unreferenced, referencedByStrike, referencedByEvent, referencedByManualEvent, recent, incomplete);
|
|
|
|
DownloadItem item = new() { DownloadId = "h1", Title = "t1" };
|
|
_context.DownloadItems.Add(item);
|
|
_context.Strikes.Add(new Strike { DownloadItemId = item.Id, JobRunId = referencedByStrike.Id, Type = StrikeType.Stalled });
|
|
_context.Events.Add(new AppEvent
|
|
{
|
|
EventType = EventType.StalledStrike,
|
|
Message = "e",
|
|
Severity = EventSeverity.Important,
|
|
JobRunId = referencedByEvent.Id,
|
|
});
|
|
_context.ManualEvents.Add(new ManualEvent
|
|
{
|
|
Type = ManualEventType.RecurringDownload,
|
|
Message = "m",
|
|
Severity = EventSeverity.Important,
|
|
JobRunId = referencedByManualEvent.Id,
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
await _service.PruneJobRunsAsync(_context, Now.AddDays(-30));
|
|
|
|
List<Guid> remaining = await _context.JobRuns.Select(j => j.Id).ToListAsync();
|
|
remaining.ShouldNotContain(unreferenced.Id);
|
|
remaining.ShouldContain(referencedByStrike.Id);
|
|
remaining.ShouldContain(referencedByEvent.Id);
|
|
remaining.ShouldContain(referencedByManualEvent.Id);
|
|
remaining.ShouldContain(recent.Id);
|
|
remaining.ShouldContain(incomplete.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupStrikesAsync_DeletesStrikesOfItemsOutsideTheInactivityWindow()
|
|
{
|
|
JobRun run = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = Now.AddDays(-2) };
|
|
_context.JobRuns.Add(run);
|
|
|
|
DownloadItem inactive = new() { DownloadId = "inactive", Title = "inactive" };
|
|
DownloadItem active = new() { DownloadId = "active", Title = "active" };
|
|
_context.DownloadItems.AddRange(inactive, active);
|
|
|
|
// The inactive item's most recent strike predates the window, so all of its strikes go.
|
|
_context.Strikes.Add(new Strike
|
|
{
|
|
DownloadItemId = inactive.Id,
|
|
JobRunId = run.Id,
|
|
Type = StrikeType.Stalled,
|
|
CreatedAt = Now.AddHours(-48),
|
|
});
|
|
_context.Strikes.Add(new Strike
|
|
{
|
|
DownloadItemId = inactive.Id,
|
|
JobRunId = run.Id,
|
|
Type = StrikeType.Stalled,
|
|
CreatedAt = Now.AddHours(-30),
|
|
});
|
|
// The active item keeps every strike, including the old one, because it was struck again recently.
|
|
_context.Strikes.Add(new Strike
|
|
{
|
|
DownloadItemId = active.Id,
|
|
JobRunId = run.Id,
|
|
Type = StrikeType.Stalled,
|
|
CreatedAt = Now.AddHours(-48),
|
|
});
|
|
_context.Strikes.Add(new Strike
|
|
{
|
|
DownloadItemId = active.Id,
|
|
JobRunId = run.Id,
|
|
Type = StrikeType.Stalled,
|
|
CreatedAt = Now.AddHours(-1),
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
await _service.CleanupStrikesAsync(_context, inactivityWindowHours: 24);
|
|
|
|
List<Guid> remainingStrikes = await _context.Strikes.Select(s => s.DownloadItemId).ToListAsync();
|
|
remainingStrikes.ShouldAllBe(id => id == active.Id);
|
|
remainingStrikes.Count.ShouldBe(2);
|
|
|
|
List<string> remainingItems = await _context.DownloadItems.Select(d => d.DownloadId).ToListAsync();
|
|
remainingItems.ShouldBe(["active"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PerformCleanupAsync_PrunesTransientRowsBeyondTheRetentionWindow()
|
|
{
|
|
DateTimeOffset old = Now.AddDays(-40);
|
|
DateTimeOffset recent = Now.AddDays(-5);
|
|
|
|
JobRun staleRun = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = old, CompletedAt = old };
|
|
JobRun recentRun = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = recent, CompletedAt = recent };
|
|
_context.JobRuns.AddRange(staleRun, recentRun);
|
|
|
|
_context.ManualEvents.Add(new ManualEvent
|
|
{
|
|
Type = ManualEventType.RecurringDownload,
|
|
Message = "stale",
|
|
Severity = EventSeverity.Warning,
|
|
Timestamp = old,
|
|
IsResolved = true,
|
|
ResolvedAt = old,
|
|
});
|
|
_context.ManualEvents.Add(new ManualEvent
|
|
{
|
|
Type = ManualEventType.RecurringDownload,
|
|
Message = "recent",
|
|
Severity = EventSeverity.Warning,
|
|
Timestamp = old,
|
|
IsResolved = true,
|
|
ResolvedAt = recent,
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
await _service.PerformCleanupAsync();
|
|
|
|
List<string> manualEvents = await _context.ManualEvents.Select(e => e.Message).ToListAsync();
|
|
manualEvents.ShouldBe(["recent"]);
|
|
|
|
List<Guid> jobRuns = await _context.JobRuns.Select(j => j.Id).ToListAsync();
|
|
jobRuns.ShouldNotContain(staleRun.Id);
|
|
jobRuns.ShouldContain(recentRun.Id);
|
|
}
|
|
}
|