doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GreyQueueWatchdogServiceTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-004 - Unit tests for watchdog service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Unknowns.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class GreyQueueWatchdogServiceTests
|
||||
{
|
||||
private readonly IGreyQueueRepository _repository;
|
||||
private readonly TestNotificationPublisher _notificationPublisher;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly GreyQueueWatchdogOptions _options;
|
||||
|
||||
public GreyQueueWatchdogServiceTests()
|
||||
{
|
||||
_repository = Substitute.For<IGreyQueueRepository>();
|
||||
_notificationPublisher = new TestNotificationPublisher();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = new GreyQueueWatchdogOptions
|
||||
{
|
||||
CheckInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProcessingAlertThreshold = TimeSpan.FromHours(1),
|
||||
ProcessingTimeout = TimeSpan.FromHours(4),
|
||||
MaxAttempts = 5,
|
||||
BaseRetryDelayMinutes = 15
|
||||
};
|
||||
}
|
||||
|
||||
#region Stuck Detection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_HealthyEntry_NoAlert()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30)); // 30 min
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert - No alerts
|
||||
Assert.Empty(_notificationPublisher.StuckAlerts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_StuckEntry_GeneratesAlert()
|
||||
{
|
||||
// Arrange - Processing for 90 min (past 1h threshold)
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert
|
||||
Assert.Single(_notificationPublisher.StuckAlerts);
|
||||
Assert.Equal(entry.Id, _notificationPublisher.StuckAlerts[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TimedOutEntry_ForcesRetry()
|
||||
{
|
||||
// Arrange - Processing for 5 hours (past 4h timeout), attempts < max
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 2);
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert
|
||||
await _repository.Received(1).ForceRetryAsync(
|
||||
entry.Id,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ExhaustedEntry_MarksFailed()
|
||||
{
|
||||
// Arrange - Processing for 5 hours, max attempts reached
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 5);
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert
|
||||
await _repository.Received(1).UpdateStatusAsync(
|
||||
entry.Id,
|
||||
GreyQueueStatus.Failed,
|
||||
Arg.Any<CancellationToken>());
|
||||
Assert.Single(_notificationPublisher.FailedNotifications);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exponential Backoff Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 15)] // Attempt 0: 15 min
|
||||
[InlineData(1, 30)] // Attempt 1: 30 min
|
||||
[InlineData(2, 60)] // Attempt 2: 60 min
|
||||
[InlineData(3, 120)] // Attempt 3: 120 min
|
||||
[InlineData(4, 240)] // Attempt 4: 240 min
|
||||
public async Task ForceRetry_UsesExponentialBackoff(int attempts, int expectedMinutes)
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: attempts);
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
DateTimeOffset capturedNextProcessingAt = default;
|
||||
_repository.ForceRetryAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Do<DateTimeOffset>(dt => capturedNextProcessingAt = dt),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert
|
||||
var expectedDelay = TimeSpan.FromMinutes(expectedMinutes);
|
||||
var actualDelay = capturedNextProcessingAt - _timeProvider.GetUtcNow();
|
||||
Assert.Equal(expectedDelay.TotalMinutes, actualDelay.TotalMinutes, precision: 1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Entries Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_MultipleEntries_ProcessesAll()
|
||||
{
|
||||
// Arrange
|
||||
var healthy = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30));
|
||||
var stuck = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
|
||||
var timedOut = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 2);
|
||||
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([healthy, stuck, timedOut]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await TriggerCheck(service);
|
||||
|
||||
// Assert
|
||||
Assert.Single(_notificationPublisher.StuckAlerts); // stuck only
|
||||
await _repository.Received(1).ForceRetryAsync(
|
||||
timedOut.Id,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Manual Retry Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ManualRetry_ProcessingEntry_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-2));
|
||||
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.ManualRetryAsync(entry.Id, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await _repository.Received(1).ForceRetryAsync(
|
||||
entry.Id,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualRetry_NonProcessingEntry_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BomRef = "pkg:test@1.0.0",
|
||||
Status = GreyQueueStatus.Pending,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ManualRetryAsync(entry.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualRetry_NotFound_Throws()
|
||||
{
|
||||
// Arrange
|
||||
_repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<GreyQueueEntry?>(null));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ManualRetryAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
var healthy = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30));
|
||||
var stuck = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
|
||||
var timedOut = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5));
|
||||
|
||||
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([healthy, stuck, timedOut]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var stats = await service.GetStatsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, stats.TotalProcessing);
|
||||
Assert.Equal(1, stats.StuckCount); // 90 min entry
|
||||
Assert.Equal(1, stats.TimedOutCount); // 5 hour entry
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private GreyQueueWatchdogService CreateService()
|
||||
{
|
||||
return new GreyQueueWatchdogService(
|
||||
_repository,
|
||||
_notificationPublisher,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
new NullLogger<GreyQueueWatchdogService>());
|
||||
}
|
||||
|
||||
private async Task TriggerCheck(GreyQueueWatchdogService service)
|
||||
{
|
||||
var method = typeof(GreyQueueWatchdogService)
|
||||
.GetMethod("CheckProcessingEntriesAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
|
||||
if (task != null) await task;
|
||||
}
|
||||
}
|
||||
|
||||
private GreyQueueEntry CreateProcessingEntry(DateTimeOffset lastProcessedAt, int attempts = 0)
|
||||
{
|
||||
return new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BomRef = $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
|
||||
Status = GreyQueueStatus.Processing,
|
||||
Score = 0.50,
|
||||
CreatedAt = lastProcessedAt.AddHours(-1),
|
||||
LastProcessedAt = lastProcessedAt,
|
||||
ProcessingAttempts = attempts,
|
||||
MaxAttempts = _options.MaxAttempts
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension to TestNotificationPublisher for watchdog notifications.
|
||||
/// </summary>
|
||||
public partial class TestNotificationPublisher
|
||||
{
|
||||
public List<StuckProcessingAlert> StuckAlerts { get; } = [];
|
||||
public List<EntryFailedNotification> FailedNotifications { get; } = [];
|
||||
public List<ForcedRetryNotification> RetryNotifications { get; } = [];
|
||||
|
||||
public new Task PublishAsync<T>(T notification, CancellationToken ct = default)
|
||||
{
|
||||
switch (notification)
|
||||
{
|
||||
case SlaWarningNotification warning:
|
||||
Warnings.Add(warning);
|
||||
break;
|
||||
case SlaBreachNotification breach:
|
||||
Breaches.Add(breach);
|
||||
break;
|
||||
case StuckProcessingAlert stuck:
|
||||
StuckAlerts.Add(stuck);
|
||||
break;
|
||||
case EntryFailedNotification failed:
|
||||
FailedNotifications.Add(failed);
|
||||
break;
|
||||
case ForcedRetryNotification retry:
|
||||
RetryNotifications.Add(retry);
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public new void Clear()
|
||||
{
|
||||
Warnings.Clear();
|
||||
Breaches.Clear();
|
||||
StuckAlerts.Clear();
|
||||
FailedNotifications.Clear();
|
||||
RetryNotifications.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsLifecycleServiceIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-002 - Integration tests for lifecycle service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Unknowns.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class UnknownsLifecycleServiceIntegrationTests
|
||||
{
|
||||
private readonly IGreyQueueRepository _repository;
|
||||
private readonly TestEventSubscriber _eventSubscriber;
|
||||
private readonly TestNotificationPublisher _notificationPublisher;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly UnknownsLifecycleOptions _options;
|
||||
|
||||
public UnknownsLifecycleServiceIntegrationTests()
|
||||
{
|
||||
_repository = Substitute.For<IGreyQueueRepository>();
|
||||
_eventSubscriber = new TestEventSubscriber();
|
||||
_notificationPublisher = new TestNotificationPublisher();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = new UnknownsLifecycleOptions
|
||||
{
|
||||
ExpiryCheckInterval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
}
|
||||
|
||||
#region EPSS Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EpssUpdated_ScoreIncreases_EscalatesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.50); // WARM
|
||||
_repository.GetByCveAsync("CVE-2026-1234", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act - Simulate EPSS update that would escalate to HOT
|
||||
await _eventSubscriber.PublishAsync(new EpssUpdatedEvent
|
||||
{
|
||||
CveId = "CVE-2026-1234",
|
||||
OldScore = 0.50,
|
||||
NewScore = 0.85
|
||||
});
|
||||
|
||||
// Assert
|
||||
await _repository.Received(1).UpdateScoreAsync(
|
||||
entry.Id,
|
||||
Arg.Is<double>(s => s >= 0.70),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EpssUpdated_ScoreDecreases_DoesNotDemote()
|
||||
{
|
||||
// Arrange - Demotion requires explicit call, not auto
|
||||
var entry = CreateEntry(0.75); // HOT
|
||||
_repository.GetByCveAsync("CVE-2026-1234", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act - EPSS decreases
|
||||
await _eventSubscriber.PublishAsync(new EpssUpdatedEvent
|
||||
{
|
||||
CveId = "CVE-2026-1234",
|
||||
OldScore = 0.75,
|
||||
NewScore = 0.30
|
||||
});
|
||||
|
||||
// Assert - Should NOT auto-demote
|
||||
await _repository.DidNotReceive().UpdateScoreAsync(
|
||||
entry.Id,
|
||||
Arg.Is<double>(s => s < 0.70),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region KEV Added Tests
|
||||
|
||||
[Fact]
|
||||
public async Task KevAdded_EscalatesToHot()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.30); // COLD
|
||||
_repository.GetByCveAsync("CVE-2026-5678", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act
|
||||
await _eventSubscriber.PublishAsync(new KevAddedEvent
|
||||
{
|
||||
CveId = "CVE-2026-5678",
|
||||
AddedDate = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert - Should escalate to HOT
|
||||
await _repository.Received(1).UpdateScoreAsync(
|
||||
entry.Id,
|
||||
Arg.Is<double>(s => s >= 0.70),
|
||||
Arg.Is<string>(r => r.Contains("KEV")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KevAdded_AlreadyHot_NoChange()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.85); // Already HOT
|
||||
_repository.GetByCveAsync("CVE-2026-5678", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act
|
||||
await _eventSubscriber.PublishAsync(new KevAddedEvent
|
||||
{
|
||||
CveId = "CVE-2026-5678",
|
||||
AddedDate = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert - Should NOT update (already HOT)
|
||||
await _repository.DidNotReceive().UpdateScoreAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<double>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deployment Created Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentCreated_ColdEntry_EscalatesToWarm()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.20, "pkg:npm/vulnerable@1.0.0"); // COLD
|
||||
_repository.GetByBomRefAsync("pkg:npm/vulnerable@1.0.0", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act
|
||||
await _eventSubscriber.PublishAsync(new DeploymentCreatedEvent
|
||||
{
|
||||
DeploymentId = "deploy-123",
|
||||
AffectedComponents = ["pkg:npm/vulnerable@1.0.0"]
|
||||
});
|
||||
|
||||
// Assert - Should escalate COLD to WARM
|
||||
await _repository.Received(1).UpdateScoreAsync(
|
||||
entry.Id,
|
||||
Arg.Is<double>(s => s >= 0.40),
|
||||
Arg.Is<string>(r => r.Contains("deployment")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentCreated_WarmEntry_NoChange()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.55, "pkg:npm/vulnerable@1.0.0"); // WARM
|
||||
_repository.GetByBomRefAsync("pkg:npm/vulnerable@1.0.0", Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
await StartServiceAsync(service);
|
||||
|
||||
// Act
|
||||
await _eventSubscriber.PublishAsync(new DeploymentCreatedEvent
|
||||
{
|
||||
DeploymentId = "deploy-123",
|
||||
AffectedComponents = ["pkg:npm/vulnerable@1.0.0"]
|
||||
});
|
||||
|
||||
// Assert - Already WARM, no change
|
||||
await _repository.DidNotReceive().UpdateScoreAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<double>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Expiry Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredEntries_AreMarkedExpired()
|
||||
{
|
||||
// Arrange
|
||||
var expiredEntry = CreateEntry(0.20);
|
||||
_repository.GetExpiredAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([expiredEntry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - Trigger expiry check
|
||||
await TriggerExpiryCheck(service);
|
||||
|
||||
// Assert
|
||||
await _repository.Received(1).UpdateStatusAsync(
|
||||
expiredEntry.Id,
|
||||
GreyQueueStatus.Expired,
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Demotion with Blocking Factors Tests
|
||||
|
||||
[Fact]
|
||||
public async Task TryDemote_WithKevBlockingFactor_DoesNotDemote()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.75); // HOT
|
||||
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
|
||||
_repository.IsInKevAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(true)); // In KEV!
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.TryDemoteEntryAsync(entry.Id, CancellationToken.None);
|
||||
|
||||
// Assert - Should NOT demote
|
||||
await _repository.DidNotReceive().UpdateScoreAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<double>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDemote_WithoutBlockingFactors_Demotes()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(0.75); // HOT
|
||||
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
|
||||
_repository.IsInKevAsync(entry.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.TryDemoteEntryAsync(entry.Id, CancellationToken.None);
|
||||
|
||||
// Assert - Should demote to WARM
|
||||
await _repository.Received(1).UpdateScoreAsync(
|
||||
entry.Id,
|
||||
Arg.Is<double>(s => s >= 0.40 && s < 0.70),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private UnknownsLifecycleService CreateService()
|
||||
{
|
||||
return new UnknownsLifecycleService(
|
||||
_repository,
|
||||
_eventSubscriber,
|
||||
_notificationPublisher,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
new NullLogger<UnknownsLifecycleService>());
|
||||
}
|
||||
|
||||
private static async Task StartServiceAsync(UnknownsLifecycleService service)
|
||||
{
|
||||
// Start the service (which registers event handlers)
|
||||
var cts = new CancellationTokenSource();
|
||||
var task = service.StartAsync(cts.Token);
|
||||
await Task.Delay(50); // Give it time to register handlers
|
||||
cts.Cancel();
|
||||
try { await task; } catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async Task TriggerExpiryCheck(UnknownsLifecycleService service)
|
||||
{
|
||||
var method = typeof(UnknownsLifecycleService)
|
||||
.GetMethod("ProcessExpiredEntriesAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
|
||||
if (task != null) await task;
|
||||
}
|
||||
}
|
||||
|
||||
private static GreyQueueEntry CreateEntry(double score, string? bomRef = null)
|
||||
{
|
||||
return new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BomRef = bomRef ?? $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
|
||||
Score = score,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test event subscriber that allows publishing test events.
|
||||
/// </summary>
|
||||
public sealed class TestEventSubscriber : IEventSubscriber
|
||||
{
|
||||
private readonly Dictionary<string, List<Delegate>> _handlers = new();
|
||||
|
||||
public void Subscribe<T>(string eventType, Func<T, CancellationToken, Task> handler)
|
||||
{
|
||||
if (!_handlers.ContainsKey(eventType))
|
||||
{
|
||||
_handlers[eventType] = [];
|
||||
}
|
||||
_handlers[eventType].Add(handler);
|
||||
}
|
||||
|
||||
public async Task PublishAsync<T>(T evt)
|
||||
{
|
||||
var eventType = typeof(T).Name.Replace("Event", "").ToLowerInvariant();
|
||||
var mappedType = eventType switch
|
||||
{
|
||||
"epssupdated" => "epss.updated",
|
||||
"kevadded" => "kev.added",
|
||||
"deploymentcreated" => "deployment.created",
|
||||
"runtimeupdated" => "runtime.updated",
|
||||
_ => eventType
|
||||
};
|
||||
|
||||
if (_handlers.TryGetValue(mappedType, out var handlers))
|
||||
{
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
if (handler is Func<T, CancellationToken, Task> typedHandler)
|
||||
{
|
||||
await typedHandler(evt, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsSlaMonitorIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-001 - Integration tests with test clock
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Unknowns.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class UnknownsSlaMonitorIntegrationTests : IAsyncDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IGreyQueueRepository _repository;
|
||||
private readonly TestNotificationPublisher _notificationPublisher;
|
||||
private readonly UnknownsMetrics _metrics;
|
||||
private readonly UnknownsSlaOptions _options;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public UnknownsSlaMonitorIntegrationTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
_repository = Substitute.For<IGreyQueueRepository>();
|
||||
_notificationPublisher = new TestNotificationPublisher();
|
||||
_metrics = new UnknownsMetrics();
|
||||
_options = new UnknownsSlaOptions
|
||||
{
|
||||
PollingInterval = TimeSpan.FromMilliseconds(100), // Fast for testing
|
||||
HotSla = TimeSpan.FromHours(24),
|
||||
WarmSla = TimeSpan.FromDays(7),
|
||||
ColdSla = TimeSpan.FromDays(30),
|
||||
WarningThreshold = 0.80
|
||||
};
|
||||
_cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_WithNoEntries_DoesNotPublishNotifications()
|
||||
{
|
||||
// Arrange
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - Run one polling cycle
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_notificationPublisher.Warnings);
|
||||
Assert.Empty(_notificationPublisher.Breaches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_WithHealthyEntry_DoesNotPublishNotifications()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-6), 0.75); // 6h of 24h = 25%
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_notificationPublisher.Warnings);
|
||||
Assert.Empty(_notificationPublisher.Breaches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_WithEntryAt80Percent_PublishesWarning()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-19.2), 0.75); // 80% of 24h
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Single(_notificationPublisher.Warnings);
|
||||
Assert.Empty(_notificationPublisher.Breaches);
|
||||
Assert.Equal(entry.Id, _notificationPublisher.Warnings[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_WithBreachedEntry_PublishesBreach()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-25), 0.75); // 25h of 24h = breached
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_notificationPublisher.Warnings);
|
||||
Assert.Single(_notificationPublisher.Breaches);
|
||||
Assert.Equal(entry.Id, _notificationPublisher.Breaches[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_TimeAdvances_EntryMovesToWarning()
|
||||
{
|
||||
// Arrange - Entry at 50%
|
||||
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-12), 0.75);
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act 1 - Check at 50%
|
||||
await RunOnePollingCycle(service);
|
||||
Assert.Empty(_notificationPublisher.Warnings);
|
||||
|
||||
// Act 2 - Advance time to 80%
|
||||
_timeProvider.Advance(TimeSpan.FromHours(7.2)); // Now at 80%
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Single(_notificationPublisher.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_TimeAdvances_EntryMovesToBreach()
|
||||
{
|
||||
// Arrange - Entry at 90%
|
||||
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-21.6), 0.75);
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act 1 - Check at 90% (warning zone)
|
||||
await RunOnePollingCycle(service);
|
||||
Assert.Single(_notificationPublisher.Warnings);
|
||||
Assert.Empty(_notificationPublisher.Breaches);
|
||||
|
||||
// Act 2 - Advance time past 100%
|
||||
_notificationPublisher.Clear();
|
||||
_timeProvider.Advance(TimeSpan.FromHours(3)); // Now at 102.5%
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_notificationPublisher.Warnings);
|
||||
Assert.Single(_notificationPublisher.Breaches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Monitor_MultipleEntries_ClassifiesByBand()
|
||||
{
|
||||
// Arrange
|
||||
var hotEntry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-20), 0.75); // HOT at 83%
|
||||
var warmEntry = CreateEntry(_timeProvider.GetUtcNow().AddDays(-6), 0.50); // WARM at 86%
|
||||
var coldEntry = CreateEntry(_timeProvider.GetUtcNow().AddDays(-10), 0.20); // COLD at 33%
|
||||
|
||||
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([hotEntry, warmEntry, coldEntry]));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await RunOnePollingCycle(service);
|
||||
|
||||
// Assert - HOT and WARM in warning, COLD is fine
|
||||
Assert.Equal(2, _notificationPublisher.Warnings.Count);
|
||||
Assert.Contains(_notificationPublisher.Warnings, w => w.EntryId == hotEntry.Id);
|
||||
Assert.Contains(_notificationPublisher.Warnings, w => w.EntryId == warmEntry.Id);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private UnknownsSlaMonitorService CreateService()
|
||||
{
|
||||
return new UnknownsSlaMonitorService(
|
||||
_repository,
|
||||
_notificationPublisher,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
_metrics,
|
||||
new NullLogger<UnknownsSlaMonitorService>());
|
||||
}
|
||||
|
||||
private async Task RunOnePollingCycle(UnknownsSlaMonitorService service)
|
||||
{
|
||||
// Use reflection to call the private CheckSlaBoundsAsync method
|
||||
var method = typeof(UnknownsSlaMonitorService)
|
||||
.GetMethod("CheckSlaBoundsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
|
||||
if (task != null) await task;
|
||||
}
|
||||
}
|
||||
|
||||
private static GreyQueueEntry CreateEntry(DateTimeOffset createdAt, double score)
|
||||
{
|
||||
return new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BomRef = $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
|
||||
Score = score,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test notification publisher that captures notifications.
|
||||
/// </summary>
|
||||
public sealed class TestNotificationPublisher : INotificationPublisher
|
||||
{
|
||||
public List<SlaWarningNotification> Warnings { get; } = [];
|
||||
public List<SlaBreachNotification> Breaches { get; } = [];
|
||||
|
||||
public Task PublishAsync<T>(T notification, CancellationToken ct = default)
|
||||
{
|
||||
switch (notification)
|
||||
{
|
||||
case SlaWarningNotification warning:
|
||||
Warnings.Add(warning);
|
||||
break;
|
||||
case SlaBreachNotification breach:
|
||||
Breaches.Add(breach);
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Warnings.Clear();
|
||||
Breaches.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null logger for testing.
|
||||
/// </summary>
|
||||
public 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) { }
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsSlaMonitorServiceTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-001 - Unit tests for SLA calculation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Unknowns.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class UnknownsSlaMonitorServiceTests
|
||||
{
|
||||
private readonly IGreyQueueRepository _repository;
|
||||
private readonly INotificationPublisher _notificationPublisher;
|
||||
private readonly UnknownsMetrics _metrics;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptions<UnknownsSlaOptions> _options;
|
||||
|
||||
public UnknownsSlaMonitorServiceTests()
|
||||
{
|
||||
_repository = Substitute.For<IGreyQueueRepository>();
|
||||
_notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
_metrics = new UnknownsMetrics();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = Options.Create(new UnknownsSlaOptions
|
||||
{
|
||||
PollingInterval = TimeSpan.FromMinutes(5),
|
||||
HotSla = TimeSpan.FromHours(24),
|
||||
WarmSla = TimeSpan.FromDays(7),
|
||||
ColdSla = TimeSpan.FromDays(30),
|
||||
WarningThreshold = 0.80
|
||||
});
|
||||
}
|
||||
|
||||
#region Band Classification Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.70, "hot")]
|
||||
[InlineData(0.85, "hot")]
|
||||
[InlineData(1.00, "hot")]
|
||||
[InlineData(0.40, "warm")]
|
||||
[InlineData(0.55, "warm")]
|
||||
[InlineData(0.69, "warm")]
|
||||
[InlineData(0.00, "cold")]
|
||||
[InlineData(0.20, "cold")]
|
||||
[InlineData(0.39, "cold")]
|
||||
public void GetBand_ReturnsCorrectBand(double score, string expectedBand)
|
||||
{
|
||||
// Arrange & Act
|
||||
var band = SlaCalculator.GetBand(score);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedBand, band.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SLA Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateSlaRemaining_HotBand_Returns24Hours()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromHours(24), remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSlaRemaining_HotBand_After12Hours_Returns12Hours()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-12);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromHours(12), remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSlaRemaining_WarmBand_Returns7Days()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var entry = CreateEntry(createdAt, 0.50);
|
||||
|
||||
// Act
|
||||
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromDays(7), remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSlaRemaining_ColdBand_Returns30Days()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var entry = CreateEntry(createdAt, 0.20);
|
||||
|
||||
// Act
|
||||
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromDays(30), remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSlaRemaining_Breached_ReturnsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-25); // 25 hours ago
|
||||
var entry = CreateEntry(createdAt, 0.75); // HOT band (24h SLA)
|
||||
|
||||
// Act
|
||||
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(remaining < TimeSpan.Zero);
|
||||
Assert.Equal(TimeSpan.FromHours(-1), remaining);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Percentage Elapsed Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0.0)]
|
||||
[InlineData(12, 0.5)]
|
||||
[InlineData(19.2, 0.8)] // 80% warning threshold
|
||||
[InlineData(24, 1.0)]
|
||||
[InlineData(48, 2.0)]
|
||||
public void CalculatePercentElapsed_HotBand_ReturnsCorrectPercentage(double hoursElapsed, double expectedPercent)
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-hoursElapsed);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var percent = SlaCalculator.CalculatePercentElapsed(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedPercent, percent, precision: 2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warning Threshold Tests
|
||||
|
||||
[Fact]
|
||||
public void IsInWarningZone_At80Percent_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-19.2); // 80% of 24h
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(isWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInWarningZone_At50Percent_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-12); // 50% of 24h
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.False(isWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInWarningZone_At100Percent_ReturnsFalse_BecauseBreached()
|
||||
{
|
||||
// Arrange - Breached is not warning, it's breach
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-25);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.False(isWarning); // Past warning, now breached
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Breach Detection Tests
|
||||
|
||||
[Fact]
|
||||
public void IsBreached_BeforeSla_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-12);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.False(isBreached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBreached_ExactlyAtSla_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-24);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(isBreached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBreached_AfterSla_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _timeProvider.GetUtcNow().AddHours(-48);
|
||||
var entry = CreateEntry(createdAt, 0.75);
|
||||
|
||||
// Act
|
||||
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(isBreached);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static GreyQueueEntry CreateEntry(DateTimeOffset createdAt, double score)
|
||||
{
|
||||
return new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BomRef = "pkg:npm/test@1.0.0",
|
||||
Score = score,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SLA calculation helper for testability.
|
||||
/// </summary>
|
||||
public static class SlaCalculator
|
||||
{
|
||||
public static UnknownsBand GetBand(double score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 0.70 => UnknownsBand.Hot,
|
||||
>= 0.40 => UnknownsBand.Warm,
|
||||
_ => UnknownsBand.Cold
|
||||
};
|
||||
}
|
||||
|
||||
public static TimeSpan GetSlaLimit(UnknownsBand band, UnknownsSlaOptions options)
|
||||
{
|
||||
return band switch
|
||||
{
|
||||
UnknownsBand.Hot => options.HotSla,
|
||||
UnknownsBand.Warm => options.WarmSla,
|
||||
UnknownsBand.Cold => options.ColdSla,
|
||||
_ => options.ColdSla
|
||||
};
|
||||
}
|
||||
|
||||
public static TimeSpan CalculateRemaining(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var band = GetBand(entry.Score);
|
||||
var slaLimit = GetSlaLimit(band, options);
|
||||
var elapsed = now - entry.CreatedAt;
|
||||
return slaLimit - elapsed;
|
||||
}
|
||||
|
||||
public static double CalculatePercentElapsed(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var band = GetBand(entry.Score);
|
||||
var slaLimit = GetSlaLimit(band, options);
|
||||
var elapsed = now - entry.CreatedAt;
|
||||
return elapsed / slaLimit;
|
||||
}
|
||||
|
||||
public static bool IsInWarningZone(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var percent = CalculatePercentElapsed(entry, now, options);
|
||||
return percent >= options.WarningThreshold && percent < 1.0;
|
||||
}
|
||||
|
||||
public static bool IsBreached(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var percent = CalculatePercentElapsed(entry, now, options);
|
||||
return percent >= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_now = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
|
||||
public void SetTime(DateTimeOffset time) => _now = time;
|
||||
}
|
||||
Reference in New Issue
Block a user