mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-13 13:58:20 -04:00
407 lines
17 KiB
C#
407 lines
17 KiB
C#
using Cleanuparr.Domain.Enums;
|
|
using Cleanuparr.Infrastructure.Health;
|
|
using Cleanuparr.Infrastructure.Models;
|
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
|
using Cleanuparr.Infrastructure.Stats;
|
|
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
|
using Cleanuparr.Persistence;
|
|
using Cleanuparr.Persistence.Models.Events;
|
|
using Cleanuparr.Persistence.Models.State;
|
|
using Cleanuparr.Persistence.Providers;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using NSubstitute;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Cleanuparr.Infrastructure.Tests.Stats;
|
|
|
|
public class StatsServiceV2Tests : IDisposable
|
|
{
|
|
private readonly EventsContext _context;
|
|
private readonly IHealthCheckService _health;
|
|
private readonly IJobManagementService _jobs;
|
|
private readonly StatsService _service;
|
|
|
|
// Midday, so seeds a few hours apart stay inside one calendar day.
|
|
private static readonly DateTimeOffset FixedNow = new(2026, 3, 18, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public StatsServiceV2Tests()
|
|
{
|
|
_context = TestEventsContextFactory.Create();
|
|
|
|
_health = Substitute.For<IHealthCheckService>();
|
|
_health.GetAllClientHealth().Returns(new Dictionary<Guid, HealthStatus>());
|
|
_health.GetAllArrInstanceHealth().Returns(new Dictionary<Guid, ArrHealthStatus>());
|
|
|
|
_jobs = Substitute.For<IJobManagementService>();
|
|
_jobs.GetAllJobs().ReturnsForAnyArgs(Task.FromResult<IReadOnlyList<JobInfo>>([]));
|
|
|
|
_service = new StatsService(
|
|
Substitute.For<ILogger<StatsService>>(), _context, _health, _jobs, new SqliteDatabaseProvider(),
|
|
new FakeTimeProvider(FixedNow));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private static AppEvent Event(
|
|
EventType type,
|
|
DeleteReason? deleteReason = null,
|
|
CleanReason? cleanReason = null,
|
|
SearchCommandStatus? searchStatus = null,
|
|
SeekerSearchReason? searchReason = null,
|
|
List<string>? grabbedItems = null,
|
|
bool isDryRun = false,
|
|
DateTimeOffset? timestamp = null) => new()
|
|
{
|
|
EventType = type,
|
|
Message = type.ToString(),
|
|
Severity = EventSeverity.Information,
|
|
Timestamp = timestamp ?? FixedNow.AddHours(-1),
|
|
DeleteReason = deleteReason,
|
|
CleanReason = cleanReason,
|
|
SearchStatus = searchStatus,
|
|
SearchReason = searchReason,
|
|
GrabbedItems = grabbedItems ?? [],
|
|
IsDryRun = isDryRun,
|
|
};
|
|
|
|
private static JobRun Run(JobType type, JobRunStatus? status, DateTimeOffset startedAt) => new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Type = type,
|
|
Status = status,
|
|
StartedAt = startedAt,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_DerivesTimeframeMetricsFromEvents()
|
|
{
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.FailedImportStrike));
|
|
_context.Events.Add(Event(EventType.StrikeReset));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.TimeframeHours.ShouldBe(24);
|
|
stats.Events.Total.ShouldBe(6);
|
|
stats.Events.ByType["StalledStrike"].ShouldBe(2);
|
|
|
|
stats.Strikes.Total.ShouldBe(3);
|
|
stats.Strikes.ByType["Stalled"].ShouldBe(2);
|
|
stats.Strikes.ByType["FailedImport"].ShouldBe(1);
|
|
stats.Strikes.Total.ShouldBe(stats.Strikes.ByType.Values.Sum());
|
|
stats.Strikes.Recovered.ShouldBe(1);
|
|
|
|
stats.Removals.Total.ShouldBe(2);
|
|
stats.Removals.ByReason["AllFilesBlocked"].ShouldBe(1);
|
|
stats.Removals.ByReason["Stalled"].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_MalwareIsDerivedFromRemovalReasons()
|
|
{
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AtLeastOneFileBlocked));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.SlowSpeed));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
int malware = stats.Removals.ByReason.GetValueOrDefault("AllFilesBlocked")
|
|
+ stats.Removals.ByReason.GetValueOrDefault("AtLeastOneFileBlocked");
|
|
malware.ShouldBe(2);
|
|
stats.Removals.Total.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_StrikesRespectTimeframe()
|
|
{
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.StalledStrike, timestamp: FixedNow.AddHours(-100)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.Strikes.Total.ShouldBe(1);
|
|
stats.Strikes.ByType["Stalled"].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_ExcludesDryRunByDefault()
|
|
{
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.StalledStrike, isDryRun: true));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response live = await _service.GetStatsV2Async(24);
|
|
live.Strikes.Total.ShouldBe(1);
|
|
live.Events.ByType["StalledStrike"].ShouldBe(1);
|
|
|
|
StatsV2Response withDryRun = await _service.GetStatsV2Async(24, includeDryRun: true);
|
|
withDryRun.Strikes.Total.ShouldBe(2);
|
|
withDryRun.Events.ByType["StalledStrike"].ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_CleanedGroupsByReasonSkippingNone()
|
|
{
|
|
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxRatioReached));
|
|
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxRatioReached));
|
|
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxSeedTimeReached));
|
|
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.None));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.Cleaned.Total.ShouldBe(4);
|
|
stats.Cleaned.ByReason["MaxRatioReached"].ShouldBe(2);
|
|
stats.Cleaned.ByReason["MaxSeedTimeReached"].ShouldBe(1);
|
|
stats.Cleaned.ByReason.ShouldNotContainKey("None");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_SearchesAggregateStatusReasonAndGrabbed()
|
|
{
|
|
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Completed,
|
|
searchReason: SeekerSearchReason.Missing, grabbedItems: ["a", "b"]));
|
|
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Completed,
|
|
searchReason: SeekerSearchReason.QualityCutoffNotMet, grabbedItems: ["c"]));
|
|
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Failed,
|
|
searchReason: SeekerSearchReason.Missing));
|
|
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.TimedOut,
|
|
searchReason: SeekerSearchReason.Replacement));
|
|
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Pending,
|
|
searchReason: SeekerSearchReason.Missing));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.Searches.Total.ShouldBe(5);
|
|
stats.Searches.Completed.ShouldBe(2);
|
|
stats.Searches.Failed.ShouldBe(2);
|
|
stats.Searches.Grabbed.ShouldBe(3);
|
|
stats.Searches.ByReason["Missing"].ShouldBe(3);
|
|
stats.Searches.ByReason["QualityCutoffNotMet"].ShouldBe(1);
|
|
stats.Searches.ByReason["Replacement"].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_FiltersByMetricAndDryRun()
|
|
{
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
|
|
_context.Events.Add(Event(EventType.StrikeReset));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.SlowSpeed, isDryRun: true));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> removed = await _service.GetTimelineAsync("removed", 24);
|
|
removed.Sum(b => b.Count).ShouldBe(2);
|
|
|
|
List<TimelineBucketDto> removedWithDryRun = await _service.GetTimelineAsync("removed", 24, includeDryRun: true);
|
|
removedWithDryRun.Sum(b => b.Count).ShouldBe(3);
|
|
|
|
List<TimelineBucketDto> malware = await _service.GetTimelineAsync("malwareBlocked", 24);
|
|
malware.Sum(b => b.Count).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_AggregatesJobRunsWithinTheTimeframe()
|
|
{
|
|
DateTimeOffset now = FixedNow;
|
|
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Completed, now.AddHours(-2)));
|
|
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Failed, now.AddHours(-1)));
|
|
_context.JobRuns.Add(Run(JobType.MalwareBlocker, JobRunStatus.Completed, now.AddHours(-3)));
|
|
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Completed, now.AddHours(-100)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.Jobs.Total.ShouldBe(3);
|
|
stats.Jobs.Completed.ShouldBe(2);
|
|
stats.Jobs.Failed.ShouldBe(1);
|
|
|
|
JobTypeV2Stats queueCleaner = stats.Jobs.ByType["QueueCleaner"];
|
|
queueCleaner.Total.ShouldBe(2);
|
|
queueCleaner.Completed.ShouldBe(1);
|
|
queueCleaner.Failed.ShouldBe(1);
|
|
queueCleaner.LastRunAt!.Value.ShouldBe(now.AddHours(-1), TimeSpan.FromSeconds(1));
|
|
queueCleaner.NextRunAt.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_JobsCarryTheNextScheduledRun()
|
|
{
|
|
DateTimeOffset now = FixedNow;
|
|
DateTimeOffset nextQueueCleanerRun = now.AddMinutes(5);
|
|
DateTimeOffset nextSeekerRun = now.AddMinutes(30);
|
|
|
|
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Completed, now.AddHours(-1)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
_jobs.GetAllJobs().ReturnsForAnyArgs(Task.FromResult<IReadOnlyList<JobInfo>>(
|
|
[
|
|
new JobInfo { JobType = nameof(JobType.QueueCleaner), NextRunTime = nextQueueCleanerRun },
|
|
new JobInfo { JobType = nameof(JobType.Seeker), NextRunTime = nextSeekerRun },
|
|
]));
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
stats.Jobs.ByType["QueueCleaner"].NextRunAt.ShouldBe(nextQueueCleanerRun);
|
|
|
|
// A job that never ran in the timeframe is still listed, with its schedule and no runs.
|
|
JobTypeV2Stats seeker = stats.Jobs.ByType["Seeker"];
|
|
seeker.Total.ShouldBe(0);
|
|
seeker.LastRunAt.ShouldBeNull();
|
|
seeker.NextRunAt.ShouldBe(nextSeekerRun);
|
|
|
|
stats.Jobs.Total.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsV2Async_ProjectsCachedHealthSnapshots()
|
|
{
|
|
Guid clientId = Guid.NewGuid();
|
|
Guid instanceId = Guid.NewGuid();
|
|
DateTimeOffset checkedAt = FixedNow.AddMinutes(-2);
|
|
|
|
_health.GetAllClientHealth().Returns(new Dictionary<Guid, HealthStatus>
|
|
{
|
|
[clientId] = new()
|
|
{
|
|
ClientId = clientId,
|
|
ClientName = "qbit",
|
|
ClientTypeName = DownloadClientTypeName.qBittorrent,
|
|
IsHealthy = false,
|
|
LastChecked = checkedAt,
|
|
ResponseTime = TimeSpan.FromMilliseconds(250),
|
|
ErrorMessage = "connection refused",
|
|
},
|
|
});
|
|
_health.GetAllArrInstanceHealth().Returns(new Dictionary<Guid, ArrHealthStatus>
|
|
{
|
|
[instanceId] = new()
|
|
{
|
|
InstanceId = instanceId,
|
|
InstanceName = "sonarr",
|
|
InstanceType = InstanceType.Sonarr,
|
|
IsHealthy = true,
|
|
LastChecked = checkedAt,
|
|
},
|
|
});
|
|
|
|
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
|
|
|
DownloadClientHealthDto client = stats.Health.DownloadClients.ShouldHaveSingleItem();
|
|
client.Id.ShouldBe(clientId);
|
|
client.Name.ShouldBe("qbit");
|
|
client.Type.ShouldBe(nameof(DownloadClientTypeName.qBittorrent));
|
|
client.IsHealthy.ShouldBeFalse();
|
|
client.LastChecked.ShouldBe(checkedAt);
|
|
client.ResponseTimeMs.ShouldBe(250);
|
|
client.ErrorMessage.ShouldBe("connection refused");
|
|
|
|
ArrInstanceHealthDto instance = stats.Health.ArrInstances.ShouldHaveSingleItem();
|
|
instance.Id.ShouldBe(instanceId);
|
|
instance.Name.ShouldBe("sonarr");
|
|
instance.Type.ShouldBe(nameof(InstanceType.Sonarr));
|
|
instance.IsHealthy.ShouldBeTrue();
|
|
instance.ErrorMessage.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_CountsStrikesOfEveryTypeAndRecoveries()
|
|
{
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.SlowSpeedStrike));
|
|
_context.Events.Add(Event(EventType.DeadTorrentStrike));
|
|
_context.Events.Add(Event(EventType.StrikeReset));
|
|
_context.Events.Add(Event(EventType.SlowTimeStrike, isDryRun: true));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> strikes = await _service.GetTimelineAsync("strikesIssued", 24);
|
|
strikes.Sum(b => b.Count).ShouldBe(3);
|
|
|
|
List<TimelineBucketDto> strikesWithDryRun = await _service.GetTimelineAsync("strikesIssued", 24, includeDryRun: true);
|
|
strikesWithDryRun.Sum(b => b.Count).ShouldBe(4);
|
|
|
|
List<TimelineBucketDto> recovered = await _service.GetTimelineAsync("recovered", 24);
|
|
recovered.Sum(b => b.Count).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_UnknownMetricCountsEveryEventType()
|
|
{
|
|
_context.Events.Add(Event(EventType.StalledStrike));
|
|
_context.Events.Add(Event(EventType.StrikeReset));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> events = await _service.GetTimelineAsync("events", 24);
|
|
events.Sum(b => b.Count).ShouldBe(3);
|
|
|
|
List<TimelineBucketDto> unknown = await _service.GetTimelineAsync("not-a-metric", 24);
|
|
unknown.Sum(b => b.Count).ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_BucketsHourlyForShortTimeframesAndDailyBeyond()
|
|
{
|
|
DateTimeOffset now = FixedNow;
|
|
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now));
|
|
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now.AddHours(-2)));
|
|
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now.AddDays(-3)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> hourly = await _service.GetTimelineAsync("recovered", 24);
|
|
hourly.Sum(b => b.Count).ShouldBe(2);
|
|
hourly.Count(b => b.Count > 0).ShouldBe(2);
|
|
|
|
List<TimelineBucketDto> daily = await _service.GetTimelineAsync("recovered", 168);
|
|
daily.Sum(b => b.Count).ShouldBe(3);
|
|
daily.Count(b => b.Count > 0).ShouldBe(2);
|
|
daily.ShouldAllBe(b => b.Date.TimeOfDay == TimeSpan.Zero);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_MonthBucketsAreFirstOfMonth()
|
|
{
|
|
DateTimeOffset now = FixedNow;
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-40)));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-75)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> series = await _service.GetTimelineAsync("removed", 8760, TimelineBucketSize.Month);
|
|
|
|
series.Sum(b => b.Count).ShouldBe(3);
|
|
series.Count(b => b.Count > 0).ShouldBe(3);
|
|
series.ShouldAllBe(b => b.Date.Day == 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTimelineAsync_WeekBucketsStartOnMonday()
|
|
{
|
|
DateTimeOffset now = FixedNow;
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-10)));
|
|
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-20)));
|
|
await _context.SaveChangesAsync();
|
|
|
|
List<TimelineBucketDto> series = await _service.GetTimelineAsync("removed", 720, TimelineBucketSize.Week);
|
|
|
|
series.Sum(b => b.Count).ShouldBe(3);
|
|
series.Count(b => b.Count > 0).ShouldBe(3);
|
|
series.ShouldAllBe(b => b.Date.DayOfWeek == DayOfWeek.Monday);
|
|
}
|
|
}
|