using Cleanuparr.Api.Controllers; using Cleanuparr.Api.Features.Events.Contracts.Responses; using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Events; using Cleanuparr.Persistence.Providers; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Shouldly; namespace Cleanuparr.Api.Tests.Controllers; public class EventsControllerTimelineTests : IDisposable { private readonly EventsContext _context; private readonly EventsController _controller; public EventsControllerTimelineTests() { _context = SeekerTestDataFactory.CreateEventsContext(); _controller = new EventsController(_context, new SqliteDatabaseProvider(), TimeProvider.System); } public void Dispose() { _context.Dispose(); GC.SuppressFinalize(this); } private static EventTypeTimelineResponse GetTimeline(ActionResult action) { OkObjectResult ok = action.Result.ShouldBeOfType(); return ok.Value.ShouldBeOfType(); } [Fact] public async Task GetTimeline_BucketsEventsByTypeAndDay() { DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime); DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero); _context.Events.Add(new AppEvent { EventType = EventType.FailedImportStrike, Message = "active a", Severity = EventSeverity.Important, Timestamp = sameDay, }); _context.Events.Add(new AppEvent { EventType = EventType.FailedImportStrike, Message = "active b", Severity = EventSeverity.Important, Timestamp = sameDay.AddHours(-1), }); _context.Events.Add(new AppEvent { EventType = EventType.StalledStrike, Message = "active c", Severity = EventSeverity.Important, Timestamp = sameDay.AddHours(-2), }); _context.Events.Add(new AppEvent { EventType = EventType.QueueItemDeleted, Message = "older removal", Severity = EventSeverity.Important, Timestamp = DateTimeOffset.UtcNow.AddDays(-10), }); await _context.SaveChangesAsync(); EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30)); timeline.Types.ShouldBe(["FailedImportStrike", "StalledStrike", "QueueItemDeleted"]); int failedImport = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("FailedImportStrike")); int stalled = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("StalledStrike")); int removed = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("QueueItemDeleted")); failedImport.ShouldBe(2); stalled.ShouldBe(1); removed.ShouldBe(1); DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart); todayBucket.Counts["FailedImportStrike"].ShouldBe(2); todayBucket.Counts["StalledStrike"].ShouldBe(1); todayBucket.Counts.ShouldNotContainKey("QueueItemDeleted"); } [Fact] public async Task GetTimeline_UsesHourlyBucketsForDayWindow() { DateTimeOffset now = DateTimeOffset.UtcNow; _context.Events.Add(new AppEvent { EventType = EventType.StalledStrike, Message = "recent", Severity = EventSeverity.Important, Timestamp = now.AddHours(-1), }); _context.Events.Add(new AppEvent { EventType = EventType.StalledStrike, Message = "earlier", Severity = EventSeverity.Important, Timestamp = now.AddHours(-3), }); await _context.SaveChangesAsync(); EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24)); int nonEmpty = timeline.Buckets.Count(b => b.Counts.GetValueOrDefault("StalledStrike") > 0); nonEmpty.ShouldBe(2); timeline.Buckets.Count.ShouldBeGreaterThan(2); } [Fact] public async Task GetTimeline_ExcludesEventsOutsideWindow() { _context.Events.Add(new AppEvent { EventType = EventType.QueueItemDeleted, Message = "too old", Severity = EventSeverity.Important, Timestamp = DateTimeOffset.UtcNow.AddDays(-40), }); await _context.SaveChangesAsync(); EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 7)); timeline.Types.ShouldBeEmpty(); timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0); } [Fact] public async Task GetTimeline_SumsEveryUnknownTypeInABucket() { DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime); DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero); _context.Events.Add(new AppEvent { EventType = EventType.FailedImportStrike, Message = "first unknown", Severity = EventSeverity.Important, Timestamp = sameDay, }); _context.Events.Add(new AppEvent { EventType = EventType.StalledStrike, Message = "second unknown", Severity = EventSeverity.Important, Timestamp = sameDay.AddHours(-1), }); await _context.SaveChangesAsync(); // Two types a newer version wrote. // This build reads both as one. await _context.Database.ExecuteSqlRawAsync( "UPDATE events SET event_type = 'fromthefuture' WHERE message = 'first unknown'"); await _context.Database.ExecuteSqlRawAsync( "UPDATE events SET event_type = 'alsofromthefuture' WHERE message = 'second unknown'"); EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30)); timeline.Types.ShouldBe([EnumSentinel.Unknown]); DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart); todayBucket.Counts[EnumSentinel.Unknown].ShouldBe(2); } }