up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,296 +1,296 @@
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<InMemoryIncidentManager>());
var digestOptions = Options.Create(new DigestOptions
{
MaxIncidentsPerDigest = 50,
TopAffectedCount = 5,
RenderContent = true,
RenderSlackBlocks = true,
SkipEmptyDigests = true
});
_generator = new DigestGenerator(
_incidentManager,
digestOptions,
_timeProvider,
new NullLogger<DigestGenerator>());
}
[Fact(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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<T> : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
}
}
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<InMemoryIncidentManager>());
var digestOptions = Options.Create(new DigestOptions
{
MaxIncidentsPerDigest = 50,
TopAffectedCount = 5,
RenderContent = true,
RenderSlackBlocks = true,
SkipEmptyDigests = true
});
_generator = new DigestGenerator(
_incidentManager,
digestOptions,
_timeProvider,
new NullLogger<DigestGenerator>());
}
[Fact(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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(Skip = "Disabled under Mongo-free in-memory mode")]
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<T> : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
}
}

View File

@@ -1,250 +1,250 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Notifier.Worker.Digest;
namespace StellaOps.Notifier.Tests.Digest;
public class InMemoryDigestSchedulerTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryDigestScheduler _scheduler;
public InMemoryDigestSchedulerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
_scheduler = new InMemoryDigestScheduler(
_timeProvider,
NullLogger<InMemoryDigestScheduler>.Instance);
}
[Fact]
public async Task UpsertScheduleAsync_CreatesNewSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.NotNull(result);
Assert.Equal("schedule-1", result.ScheduleId);
Assert.NotNull(result.NextRunAt);
}
[Fact]
public async Task UpsertScheduleAsync_UpdatesExistingSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
await _scheduler.UpsertScheduleAsync(schedule);
var updated = schedule with { Name = "Updated Name" };
// Act
var result = await _scheduler.UpsertScheduleAsync(updated);
// Assert
Assert.Equal("Updated Name", result.Name);
}
[Fact]
public async Task GetScheduleAsync_ReturnsSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
await _scheduler.UpsertScheduleAsync(schedule);
// Act
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
// Assert
Assert.NotNull(result);
Assert.Equal("schedule-1", result.ScheduleId);
}
[Fact]
public async Task GetScheduleAsync_ReturnsNullForUnknown()
{
// Act
var result = await _scheduler.GetScheduleAsync("tenant1", "unknown");
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetSchedulesAsync_ReturnsTenantSchedules()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1", "tenant1"));
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-2", "tenant1"));
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-3", "tenant2"));
// Act
var result = await _scheduler.GetSchedulesAsync("tenant1");
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, s => Assert.Equal("tenant1", s.TenantId));
}
[Fact]
public async Task DeleteScheduleAsync_RemovesSchedule()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1"));
// Act
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "schedule-1");
// Assert
Assert.True(deleted);
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
Assert.Null(result);
}
[Fact]
public async Task DeleteScheduleAsync_ReturnsFalseForUnknown()
{
// Act
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "unknown");
// Assert
Assert.False(deleted);
}
[Fact]
public async Task GetDueSchedulesAsync_ReturnsDueSchedules()
{
// Arrange - create a schedule that should run every minute
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 * * * * *" // Every minute
};
await _scheduler.UpsertScheduleAsync(schedule);
// Advance time past next run
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
// Assert
Assert.Single(dueSchedules);
Assert.Equal("schedule-1", dueSchedules[0].ScheduleId);
}
[Fact]
public async Task GetDueSchedulesAsync_ExcludesDisabledSchedules()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
Enabled = false,
CronExpression = "0 * * * * *"
};
await _scheduler.UpsertScheduleAsync(schedule);
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
// Assert
Assert.Empty(dueSchedules);
}
[Fact]
public async Task UpdateLastRunAsync_UpdatesTimestamps()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 0 * * * *" // Every hour
};
await _scheduler.UpsertScheduleAsync(schedule);
var runTime = _timeProvider.GetUtcNow();
// Act
await _scheduler.UpdateLastRunAsync("tenant1", "schedule-1", runTime);
// Assert
var updated = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
Assert.NotNull(updated);
Assert.Equal(runTime, updated.LastRunAt);
Assert.NotNull(updated.NextRunAt);
Assert.True(updated.NextRunAt > runTime);
}
[Fact]
public async Task UpsertScheduleAsync_CalculatesNextRunWithTimezone()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 0 9 * * *", // 9 AM every day
Timezone = "America/New_York"
};
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.NotNull(result.NextRunAt);
}
[Fact]
public async Task UpsertScheduleAsync_HandlesInvalidCron()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "invalid-cron"
};
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.Null(result.NextRunAt);
}
[Fact]
public async Task GetSchedulesAsync_OrdersByName()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-c") with { Name = "Charlie" });
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-a") with { Name = "Alpha" });
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-b") with { Name = "Bravo" });
// Act
var result = await _scheduler.GetSchedulesAsync("tenant1");
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("Alpha", result[0].Name);
Assert.Equal("Bravo", result[1].Name);
Assert.Equal("Charlie", result[2].Name);
}
private DigestSchedule CreateTestSchedule(string id, string tenantId = "tenant1")
{
return new DigestSchedule
{
ScheduleId = id,
TenantId = tenantId,
Name = $"Test Schedule {id}",
Enabled = true,
CronExpression = "0 0 8 * * *", // 8 AM daily
DigestType = DigestType.Daily,
Format = DigestFormat.Html,
CreatedAt = _timeProvider.GetUtcNow(),
Recipients =
[
new DigestRecipient { Type = "email", Address = "test@example.com" }
]
};
}
}
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Notifier.Worker.Digest;
namespace StellaOps.Notifier.Tests.Digest;
public class InMemoryDigestSchedulerTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryDigestScheduler _scheduler;
public InMemoryDigestSchedulerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
_scheduler = new InMemoryDigestScheduler(
_timeProvider,
NullLogger<InMemoryDigestScheduler>.Instance);
}
[Fact]
public async Task UpsertScheduleAsync_CreatesNewSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.NotNull(result);
Assert.Equal("schedule-1", result.ScheduleId);
Assert.NotNull(result.NextRunAt);
}
[Fact]
public async Task UpsertScheduleAsync_UpdatesExistingSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
await _scheduler.UpsertScheduleAsync(schedule);
var updated = schedule with { Name = "Updated Name" };
// Act
var result = await _scheduler.UpsertScheduleAsync(updated);
// Assert
Assert.Equal("Updated Name", result.Name);
}
[Fact]
public async Task GetScheduleAsync_ReturnsSchedule()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1");
await _scheduler.UpsertScheduleAsync(schedule);
// Act
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
// Assert
Assert.NotNull(result);
Assert.Equal("schedule-1", result.ScheduleId);
}
[Fact]
public async Task GetScheduleAsync_ReturnsNullForUnknown()
{
// Act
var result = await _scheduler.GetScheduleAsync("tenant1", "unknown");
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetSchedulesAsync_ReturnsTenantSchedules()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1", "tenant1"));
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-2", "tenant1"));
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-3", "tenant2"));
// Act
var result = await _scheduler.GetSchedulesAsync("tenant1");
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, s => Assert.Equal("tenant1", s.TenantId));
}
[Fact]
public async Task DeleteScheduleAsync_RemovesSchedule()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1"));
// Act
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "schedule-1");
// Assert
Assert.True(deleted);
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
Assert.Null(result);
}
[Fact]
public async Task DeleteScheduleAsync_ReturnsFalseForUnknown()
{
// Act
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "unknown");
// Assert
Assert.False(deleted);
}
[Fact]
public async Task GetDueSchedulesAsync_ReturnsDueSchedules()
{
// Arrange - create a schedule that should run every minute
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 * * * * *" // Every minute
};
await _scheduler.UpsertScheduleAsync(schedule);
// Advance time past next run
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
// Assert
Assert.Single(dueSchedules);
Assert.Equal("schedule-1", dueSchedules[0].ScheduleId);
}
[Fact]
public async Task GetDueSchedulesAsync_ExcludesDisabledSchedules()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
Enabled = false,
CronExpression = "0 * * * * *"
};
await _scheduler.UpsertScheduleAsync(schedule);
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
// Assert
Assert.Empty(dueSchedules);
}
[Fact]
public async Task UpdateLastRunAsync_UpdatesTimestamps()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 0 * * * *" // Every hour
};
await _scheduler.UpsertScheduleAsync(schedule);
var runTime = _timeProvider.GetUtcNow();
// Act
await _scheduler.UpdateLastRunAsync("tenant1", "schedule-1", runTime);
// Assert
var updated = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
Assert.NotNull(updated);
Assert.Equal(runTime, updated.LastRunAt);
Assert.NotNull(updated.NextRunAt);
Assert.True(updated.NextRunAt > runTime);
}
[Fact]
public async Task UpsertScheduleAsync_CalculatesNextRunWithTimezone()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "0 0 9 * * *", // 9 AM every day
Timezone = "America/New_York"
};
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.NotNull(result.NextRunAt);
}
[Fact]
public async Task UpsertScheduleAsync_HandlesInvalidCron()
{
// Arrange
var schedule = CreateTestSchedule("schedule-1") with
{
CronExpression = "invalid-cron"
};
// Act
var result = await _scheduler.UpsertScheduleAsync(schedule);
// Assert
Assert.Null(result.NextRunAt);
}
[Fact]
public async Task GetSchedulesAsync_OrdersByName()
{
// Arrange
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-c") with { Name = "Charlie" });
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-a") with { Name = "Alpha" });
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-b") with { Name = "Bravo" });
// Act
var result = await _scheduler.GetSchedulesAsync("tenant1");
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("Alpha", result[0].Name);
Assert.Equal("Bravo", result[1].Name);
Assert.Equal("Charlie", result[2].Name);
}
private DigestSchedule CreateTestSchedule(string id, string tenantId = "tenant1")
{
return new DigestSchedule
{
ScheduleId = id,
TenantId = tenantId,
Name = $"Test Schedule {id}",
Enabled = true,
CronExpression = "0 0 8 * * *", // 8 AM daily
DigestType = DigestType.Daily,
Format = DigestFormat.Html,
CreatedAt = _timeProvider.GetUtcNow(),
Recipients =
[
new DigestRecipient { Type = "email", Address = "test@example.com" }
]
};
}
}