using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Notifier.Worker.Correlation; using StellaOps.Notifier.Worker.Digest; using Xunit; namespace StellaOps.Notifier.Tests.Digest; public sealed class DigestGeneratorTests { private readonly InMemoryIncidentManager _incidentManager; private readonly DigestGenerator _generator; private readonly FakeTimeProvider _timeProvider; public DigestGeneratorTests() { _timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-27T12:00:00Z")); var incidentOptions = Options.Create(new IncidentManagerOptions { CorrelationWindow = TimeSpan.FromHours(1), ReopenOnNewEvent = true }); _incidentManager = new InMemoryIncidentManager( incidentOptions, _timeProvider, new NullLogger()); var digestOptions = Options.Create(new DigestOptions { MaxIncidentsPerDigest = 50, TopAffectedCount = 5, RenderContent = true, RenderSlackBlocks = true, SkipEmptyDigests = true }); _generator = new DigestGenerator( _incidentManager, digestOptions, _timeProvider, new NullLogger()); } [Fact] public async Task GenerateAsync_EmptyTenant_ReturnsEmptyDigest() { // Arrange var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow()); // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.NotNull(result); Assert.Equal("tenant-1", result.TenantId); Assert.Empty(result.Incidents); Assert.Equal(0, result.Summary.TotalEvents); Assert.Equal(0, result.Summary.NewIncidents); Assert.False(result.Summary.HasActivity); } [Fact] public async Task GenerateAsync_WithIncidents_ReturnsSummary() { // Arrange var incident = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "vuln:critical:pkg-foo", "vulnerability.detected", "Critical vulnerability in pkg-foo"); await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1"); await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-2"); var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow()); // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.Single(result.Incidents); Assert.Equal(2, result.Summary.TotalEvents); Assert.Equal(1, result.Summary.NewIncidents); Assert.Equal(1, result.Summary.OpenIncidents); Assert.True(result.Summary.HasActivity); } [Fact] public async Task GenerateAsync_MultipleIncidents_GroupsByEventKind() { // Arrange var inc1 = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key1", "vulnerability.detected", "Vuln 1"); await _incidentManager.RecordEventAsync("tenant-1", inc1.IncidentId, "evt-1"); var inc2 = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key2", "vulnerability.detected", "Vuln 2"); await _incidentManager.RecordEventAsync("tenant-1", inc2.IncidentId, "evt-2"); var inc3 = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key3", "pack.approval.required", "Approval needed"); await _incidentManager.RecordEventAsync("tenant-1", inc3.IncidentId, "evt-3"); var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow()); // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.Equal(3, result.Incidents.Count); Assert.Equal(3, result.Summary.TotalEvents); Assert.Contains("vulnerability.detected", result.Summary.ByEventKind.Keys); Assert.Contains("pack.approval.required", result.Summary.ByEventKind.Keys); Assert.Equal(2, result.Summary.ByEventKind["vulnerability.detected"]); Assert.Equal(1, result.Summary.ByEventKind["pack.approval.required"]); } [Fact] public async Task GenerateAsync_RendersContent() { // Arrange var incident = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key", "vulnerability.detected", "Critical issue"); await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1"); var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow()); // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.NotNull(result.Content); Assert.NotEmpty(result.Content.PlainText!); Assert.NotEmpty(result.Content.Markdown!); Assert.NotEmpty(result.Content.Html!); Assert.NotEmpty(result.Content.Json!); Assert.NotEmpty(result.Content.SlackBlocks!); Assert.Contains("Notification Digest", result.Content.PlainText); Assert.Contains("tenant-1", result.Content.PlainText); Assert.Contains("Critical issue", result.Content.PlainText); } [Fact] public async Task GenerateAsync_RespectsMaxIncidents() { // Arrange for (var i = 0; i < 10; i++) { var inc = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", $"key-{i}", "test.event", $"Test incident {i}"); await _incidentManager.RecordEventAsync("tenant-1", inc.IncidentId, $"evt-{i}"); } var query = new DigestQuery { From = _timeProvider.GetUtcNow().AddDays(-1), To = _timeProvider.GetUtcNow(), MaxIncidents = 5 }; // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.Equal(5, result.Incidents.Count); Assert.Equal(10, result.TotalIncidentCount); Assert.True(result.HasMore); } [Fact] public async Task GenerateAsync_FiltersResolvedIncidents() { // Arrange var openInc = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key-open", "test.event", "Open incident"); await _incidentManager.RecordEventAsync("tenant-1", openInc.IncidentId, "evt-1"); var resolvedInc = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key-resolved", "test.event", "Resolved incident"); await _incidentManager.RecordEventAsync("tenant-1", resolvedInc.IncidentId, "evt-2"); await _incidentManager.ResolveAsync("tenant-1", resolvedInc.IncidentId, "system", "Auto-resolved"); var queryExcludeResolved = new DigestQuery { From = _timeProvider.GetUtcNow().AddDays(-1), To = _timeProvider.GetUtcNow(), IncludeResolved = false }; var queryIncludeResolved = new DigestQuery { From = _timeProvider.GetUtcNow().AddDays(-1), To = _timeProvider.GetUtcNow(), IncludeResolved = true }; // Act var resultExclude = await _generator.GenerateAsync("tenant-1", queryExcludeResolved); var resultInclude = await _generator.GenerateAsync("tenant-1", queryIncludeResolved); // Assert Assert.Single(resultExclude.Incidents); Assert.Equal("Open incident", resultExclude.Incidents[0].Title); Assert.Equal(2, resultInclude.Incidents.Count); } [Fact] public async Task GenerateAsync_FiltersEventKinds() { // Arrange var vulnInc = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key-vuln", "vulnerability.detected", "Vulnerability"); await _incidentManager.RecordEventAsync("tenant-1", vulnInc.IncidentId, "evt-1"); var approvalInc = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key-approval", "pack.approval.required", "Approval"); await _incidentManager.RecordEventAsync("tenant-1", approvalInc.IncidentId, "evt-2"); var query = new DigestQuery { From = _timeProvider.GetUtcNow().AddDays(-1), To = _timeProvider.GetUtcNow(), EventKinds = ["vulnerability.detected"] }; // Act var result = await _generator.GenerateAsync("tenant-1", query); // Assert Assert.Single(result.Incidents); Assert.Equal("vulnerability.detected", result.Incidents[0].EventKind); } [Fact] public async Task PreviewAsync_SetsIsPreviewFlag() { // Arrange var incident = await _incidentManager.GetOrCreateIncidentAsync( "tenant-1", "key", "test.event", "Test"); await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1"); var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow()); // Act var result = await _generator.PreviewAsync("tenant-1", query); // Assert Assert.True(result.IsPreview); } [Fact] public void DigestQuery_LastHours_CalculatesCorrectWindow() { // Arrange var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z"); // Act var query = DigestQuery.LastHours(6, asOf); // Assert Assert.Equal(DateTimeOffset.Parse("2025-11-27T06:00:00Z"), query.From); Assert.Equal(asOf, query.To); } [Fact] public void DigestQuery_LastDays_CalculatesCorrectWindow() { // Arrange var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z"); // Act var query = DigestQuery.LastDays(7, asOf); // Assert Assert.Equal(DateTimeOffset.Parse("2025-11-20T12:00:00Z"), query.From); Assert.Equal(asOf, query.To); } private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _utcNow; public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow; public override DateTimeOffset GetUtcNow() => _utcNow; public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); } private sealed class NullLogger : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => false; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } } }