Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for acknowledgment bridge.
|
||||
/// </summary>
|
||||
public sealed class AckBridgeTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IEscalationEngine> _escalationEngine;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly AckBridgeOptions _options;
|
||||
private readonly AckBridge _bridge;
|
||||
|
||||
public AckBridgeTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_escalationEngine = new Mock<IEscalationEngine>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
|
||||
_options = new AckBridgeOptions
|
||||
{
|
||||
AckBaseUrl = "https://notify.example.com",
|
||||
SigningKey = "test-signing-key-for-unit-tests",
|
||||
DefaultTokenExpiry = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
_bridge = new AckBridge(
|
||||
_escalationEngine.Object,
|
||||
_incidentManager.Object,
|
||||
null,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<AckBridge>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAckLink_CreatesValidUrl()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
link.Should().StartWith("https://notify.example.com/ack?token=");
|
||||
link.Should().Contain("token=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithValidToken_ReturnsValid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
result.TargetId.Should().Be("user-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithExpiredToken_ReturnsInvalid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromMinutes(30));
|
||||
|
||||
var token = ExtractToken(link);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithTamperedToken_ReturnsInvalid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var tamperedToken = token.Substring(0, token.Length - 5) + "XXXXX";
|
||||
|
||||
var result = await _bridge.ValidateTokenAsync(tamperedToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithMalformedToken_ReturnsInvalid()
|
||||
{
|
||||
var result = await _bridge.ValidateTokenAsync("not-a-valid-token");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid token format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithSignedLink_ProcessesSuccessfully()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = token,
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithInvalidToken_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = "invalid-token",
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithDirectIds_ProcessesSuccessfully()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithExternalId_ResolvesMapping()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Register the external ID mapping
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-alert-123", "tenant-1", "incident-1");
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.PagerDuty,
|
||||
ExternalId = "pd-alert-123",
|
||||
AcknowledgedBy = "pagerduty"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithUnknownExternalId_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.PagerDuty,
|
||||
ExternalId = "unknown-external-id",
|
||||
AcknowledgedBy = "pagerduty"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Unknown external ID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithMissingIds_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Could not resolve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAckLink_UsesDefaultExpiry()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ExpiresAt.Should().BeCloseTo(
|
||||
_timeProvider.GetUtcNow().Add(_options.DefaultTokenExpiry),
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterExternalId_AllowsMultipleMappings()
|
||||
{
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-1", "tenant-1", "incident-1");
|
||||
_bridge.RegisterExternalId(AckSource.OpsGenie, "og-1", "tenant-1", "incident-2");
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-2", "tenant-2", "incident-3");
|
||||
|
||||
// Verify by trying to resolve (indirectly through ProcessAckAsync)
|
||||
// This is validated by the ProcessAck_WithExternalId_ResolvesMapping test
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_ReturnsExpiresAt()
|
||||
{
|
||||
var expiry = TimeSpan.FromHours(2);
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
expiry);
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ExpiresAt.Should().BeCloseTo(
|
||||
_timeProvider.GetUtcNow().Add(expiry),
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private static string ExtractToken(string link)
|
||||
{
|
||||
var uri = new Uri(link);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
return query["token"] ?? throw new InvalidOperationException("Token not found in URL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for escalation engine.
|
||||
/// </summary>
|
||||
public sealed class EscalationEngineTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IEscalationPolicyService> _policyService;
|
||||
private readonly Mock<IOnCallScheduleService> _scheduleService;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly EscalationEngine _engine;
|
||||
|
||||
public EscalationEngineTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_policyService = new Mock<IEscalationPolicyService>();
|
||||
_scheduleService = new Mock<IOnCallScheduleService>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
|
||||
_engine = new EscalationEngine(
|
||||
_policyService.Object,
|
||||
_scheduleService.Object,
|
||||
_incidentManager.Object,
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<EscalationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_WithValidPolicy_ReturnsStartedState()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
result.PolicyId.Should().Be("policy-1");
|
||||
result.Status.Should().Be(EscalationStatus.InProgress);
|
||||
result.CurrentLevel.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_WithNonexistentPolicy_ReturnsFailedState()
|
||||
{
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EscalationPolicy?)null);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "nonexistent");
|
||||
|
||||
result.Status.Should().Be(EscalationStatus.Failed);
|
||||
result.ErrorMessage.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEscalationState_AfterStart_ReturnsState()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEscalationState_WhenNotStarted_ReturnsNull()
|
||||
{
|
||||
var result = await _engine.GetEscalationStateAsync("tenant-1", "nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAcknowledgment_StopsEscalation()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(EscalationStatus.Acknowledged);
|
||||
result.AcknowledgedBy.Should().Be("user-1");
|
||||
result.AcknowledgedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAcknowledgment_WhenNotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "nonexistent", "user-1");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalate_MovesToNextLevel()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Manual escalation", "admin");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.CurrentLevel.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalate_AtMaxLevel_StartsNewCycle()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
await _engine.EscalateAsync("tenant-1", "incident-1", "First escalation", "admin");
|
||||
|
||||
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Second escalation", "admin");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.CurrentLevel.Should().Be(1);
|
||||
result.CycleCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopEscalation_SetsResolvedStatus()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.StopEscalationAsync("tenant-1", "incident-1", "Incident resolved", "admin");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var state = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
|
||||
state!.Status.Should().Be(EscalationStatus.Resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListActiveEscalations_ReturnsOnlyInProgress()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-2", "policy-1");
|
||||
await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
|
||||
|
||||
var result = await _engine.ListActiveEscalationsAsync("tenant-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].IncidentId.Should().Be("incident-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPendingEscalations_EscalatesOverdueItems()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
// Advance time past the escalation delay (5 minutes)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
var actions = await _engine.ProcessPendingEscalationsAsync();
|
||||
|
||||
actions.Should().NotBeEmpty();
|
||||
actions[0].ActionType.Should().Be("Escalate");
|
||||
actions[0].IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPendingEscalations_DoesNotEscalateBeforeDelay()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
// Advance time but not past the delay (5 minutes)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(3));
|
||||
|
||||
var actions = await _engine.ProcessPendingEscalationsAsync();
|
||||
|
||||
actions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_ResolvesOnCallTargets()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new OnCallUser { UserId = "user-1", UserName = "User One", Email = "user1@example.com" },
|
||||
new OnCallUser { UserId = "user-2", UserName = "User Two", Email = "user2@example.com" }
|
||||
]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.ResolvedTargets.Should().HaveCount(2);
|
||||
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-1");
|
||||
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_RecordsHistoryEntry()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.History.Should().HaveCount(1);
|
||||
result.History[0].Action.Should().Be("Started");
|
||||
result.History[0].Level.Should().Be(1);
|
||||
}
|
||||
|
||||
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
|
||||
{
|
||||
PolicyId = policyId,
|
||||
TenantId = tenantId,
|
||||
Name = "Default Escalation",
|
||||
IsDefault = true,
|
||||
Levels =
|
||||
[
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 1,
|
||||
DelayMinutes = 5,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.OnCallSchedule,
|
||||
TargetId = "schedule-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 2,
|
||||
DelayMinutes = 15,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.User,
|
||||
TargetId = "manager-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for escalation policy service.
|
||||
/// </summary>
|
||||
public sealed class EscalationPolicyServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryEscalationPolicyService _service;
|
||||
|
||||
public EscalationPolicyServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new InMemoryEscalationPolicyService(
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryEscalationPolicyService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPolicies_WhenEmpty_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _service.ListPoliciesAsync("tenant-1");
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_CreatesNewPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
|
||||
var result = await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
result.PolicyId.Should().Be("policy-1");
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.Name.Should().Be("Default Escalation");
|
||||
result.Levels.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_UpdatesExistingPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var updated = policy with { Name = "Updated Policy" };
|
||||
var result = await _service.UpsertPolicyAsync(updated, "admin");
|
||||
|
||||
result.Name.Should().Be("Updated Policy");
|
||||
|
||||
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
retrieved!.Name.Should().Be("Updated Policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPolicy_WhenExists_ReturnsPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPolicy_WhenNotExists_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetPolicyAsync("tenant-1", "nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicy_WhenExists_ReturnsTrue()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.DeletePolicyAsync("tenant-1", "policy-1", "admin");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicy_WhenNotExists_ReturnsFalse()
|
||||
{
|
||||
var result = await _service.DeletePolicyAsync("tenant-1", "nonexistent", "admin");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDefaultPolicy_ReturnsFirstDefaultPolicy()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with { IsDefault = true };
|
||||
var policy3 = CreateTestPolicy("tenant-1", "policy-3") with { IsDefault = true };
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
await _service.UpsertPolicyAsync(policy3, "admin");
|
||||
|
||||
var result = await _service.GetDefaultPolicyAsync("tenant-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.IsDefault.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDefaultPolicy_WhenNoneDefault_ReturnsNull()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.GetDefaultPolicyAsync("tenant-1");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_FiltersByEventKind()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
|
||||
{
|
||||
EventKindFilter = ["scan.*", "vulnerability.*"]
|
||||
};
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
|
||||
{
|
||||
EventKindFilter = ["compliance.*"]
|
||||
};
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "scan.completed", null);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_FiltersBySeverity()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
|
||||
{
|
||||
SeverityFilter = ["critical", "high"]
|
||||
};
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
|
||||
{
|
||||
SeverityFilter = ["low"]
|
||||
};
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "incident.created", "critical");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_ReturnsAllWhenNoFilters()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2");
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "any.event", null);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPolicies_IsolatesByTenant()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
|
||||
var policy2 = CreateTestPolicy("tenant-2", "policy-2");
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var tenant1Policies = await _service.ListPoliciesAsync("tenant-1");
|
||||
var tenant2Policies = await _service.ListPoliciesAsync("tenant-2");
|
||||
|
||||
tenant1Policies.Should().HaveCount(1);
|
||||
tenant1Policies[0].PolicyId.Should().Be("policy-1");
|
||||
|
||||
tenant2Policies.Should().HaveCount(1);
|
||||
tenant2Policies[0].PolicyId.Should().Be("policy-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_SetsTimestamps()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
|
||||
var result = await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
result.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
result.UpdatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
|
||||
{
|
||||
PolicyId = policyId,
|
||||
TenantId = tenantId,
|
||||
Name = "Default Escalation",
|
||||
IsDefault = true,
|
||||
Levels =
|
||||
[
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 1,
|
||||
DelayMinutes = 5,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.OnCallSchedule,
|
||||
TargetId = "schedule-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 2,
|
||||
DelayMinutes = 15,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.User,
|
||||
TargetId = "manager-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for inbox channel adapters.
|
||||
/// </summary>
|
||||
public sealed class InboxChannelTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InAppInboxChannel _inboxChannel;
|
||||
private readonly CliNotificationChannel _cliChannel;
|
||||
|
||||
public InboxChannelTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_inboxChannel = new InAppInboxChannel(
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<InAppInboxChannel>.Instance);
|
||||
|
||||
_cliChannel = new CliNotificationChannel(
|
||||
_timeProvider,
|
||||
NullLogger<CliNotificationChannel>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_SendAsync_CreatesNotification()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.SendAsync(notification);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_ReturnsNotifications()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _inboxChannel.SendAsync(notification);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersUnread()
|
||||
{
|
||||
var notif1 = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
var notif2 = CreateTestNotification("tenant-1", "user-1", "notif-2");
|
||||
|
||||
await _inboxChannel.SendAsync(notif1);
|
||||
await _inboxChannel.SendAsync(notif2);
|
||||
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1", new InboxQuery { IsRead = false });
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersByType()
|
||||
{
|
||||
var incident = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
Type = InboxNotificationType.Incident
|
||||
};
|
||||
var system = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
Type = InboxNotificationType.System
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(incident);
|
||||
await _inboxChannel.SendAsync(system);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { Type = InboxNotificationType.Incident });
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersByMinPriority()
|
||||
{
|
||||
var low = CreateTestNotification("tenant-1", "user-1", "notif-1") with { Priority = InboxPriority.Low };
|
||||
var high = CreateTestNotification("tenant-1", "user-1", "notif-2") with { Priority = InboxPriority.High };
|
||||
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-3") with { Priority = InboxPriority.Urgent };
|
||||
|
||||
await _inboxChannel.SendAsync(low);
|
||||
await _inboxChannel.SendAsync(high);
|
||||
await _inboxChannel.SendAsync(urgent);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { MinPriority = InboxPriority.High });
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().OnlyContain(n => n.Priority >= InboxPriority.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_ExcludesExpired()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_IncludesExpiredWhenRequested()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { IncludeExpired = true });
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_RespectsLimit()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", $"notif-{i}"));
|
||||
}
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { Limit = 5 });
|
||||
|
||||
result.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkReadAsync_MarksNotificationAsRead()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _inboxChannel.SendAsync(notification);
|
||||
|
||||
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var list = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
list[0].IsRead.Should().BeTrue();
|
||||
list[0].ReadAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkReadAsync_ReturnsFalseForNonexistent()
|
||||
{
|
||||
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "nonexistent");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkAllReadAsync_MarksAllAsRead()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
|
||||
|
||||
var result = await _inboxChannel.MarkAllReadAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(3);
|
||||
|
||||
var unread = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
unread.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_GetUnreadCountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
|
||||
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_GetUnreadCountAsync_ExcludesExpired()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_IsolatesByTenantAndUser()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-2", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-2", "user-1", "notif-3"));
|
||||
|
||||
var tenant1User1 = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
var tenant1User2 = await _inboxChannel.ListAsync("tenant-1", "user-2");
|
||||
var tenant2User1 = await _inboxChannel.ListAsync("tenant-2", "user-1");
|
||||
|
||||
tenant1User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-1");
|
||||
tenant1User2.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-2");
|
||||
tenant2User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_SortsByPriorityAndCreatedAt()
|
||||
{
|
||||
var low = CreateTestNotification("tenant-1", "user-1", "notif-low") with { Priority = InboxPriority.Low };
|
||||
await _inboxChannel.SendAsync(low);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var high = CreateTestNotification("tenant-1", "user-1", "notif-high") with { Priority = InboxPriority.High };
|
||||
await _inboxChannel.SendAsync(high);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-urgent") with { Priority = InboxPriority.Urgent };
|
||||
await _inboxChannel.SendAsync(urgent);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result[0].NotificationId.Should().Be("notif-urgent");
|
||||
result[1].NotificationId.Should().Be("notif-high");
|
||||
result[2].NotificationId.Should().Be("notif-low");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_SendAsync_CreatesNotification()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _cliChannel.SendAsync(notification);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_ListAsync_ReturnsNotifications()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _cliChannel.SendAsync(notification);
|
||||
|
||||
var result = await _cliChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_FormatForCli_FormatsCorrectly()
|
||||
{
|
||||
var notification = new InboxNotification
|
||||
{
|
||||
NotificationId = "notif-1",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Critical Alert",
|
||||
Body = "Server down",
|
||||
Priority = InboxPriority.Urgent,
|
||||
IsRead = false,
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var formatted = CliNotificationChannel.FormatForCli(notification);
|
||||
|
||||
formatted.Should().Contain("[!!!]");
|
||||
formatted.Should().Contain("●");
|
||||
formatted.Should().Contain("Critical Alert");
|
||||
formatted.Should().Contain("Server down");
|
||||
formatted.Should().Contain("2025-01-15");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_FormatForCli_ShowsReadMarker()
|
||||
{
|
||||
var notification = new InboxNotification
|
||||
{
|
||||
NotificationId = "notif-1",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Alert",
|
||||
Body = "Details",
|
||||
Priority = InboxPriority.Normal,
|
||||
IsRead = true,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var formatted = CliNotificationChannel.FormatForCli(notification);
|
||||
|
||||
formatted.Should().NotContain("●");
|
||||
formatted.Should().Contain("[*]");
|
||||
}
|
||||
|
||||
private static InboxNotification CreateTestNotification(string tenantId, string userId, string notificationId) => new()
|
||||
{
|
||||
NotificationId = notificationId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Test Alert",
|
||||
Body = "This is a test notification",
|
||||
Priority = InboxPriority.Normal
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user