Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PlaybookSuggestionService.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class PlaybookSuggestionServiceTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryStore> _storeMock;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly PlaybookSuggestionService _service;
|
||||
|
||||
public PlaybookSuggestionServiceTests()
|
||||
{
|
||||
_storeMock = new Mock<IOpsMemoryStore>();
|
||||
_vectorGenerator = new SimilarityVectorGenerator();
|
||||
_service = new PlaybookSuggestionService(
|
||||
_storeMock.Object,
|
||||
_vectorGenerator,
|
||||
NullLogger<PlaybookSuggestionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_WithNoSimilarRecords_ReturnsEmptySuggestions()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<SimilarityMatch>());
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-12345",
|
||||
Severity = "high"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().BeEmpty();
|
||||
result.AnalyzedRecords.Should().Be(0);
|
||||
result.HasSuggestions.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_WithSimilarRecords_ReturnsSuggestions()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Record = pastRecord,
|
||||
SimilarityScore = 0.85
|
||||
}
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-12345",
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
result.AnalyzedRecords.Should().Be(1);
|
||||
result.HasSuggestions.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_GroupsByAction_AndRanksBySuccessRate()
|
||||
{
|
||||
// Arrange - multiple records with same action
|
||||
var remediateSuccess1 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var remediateSuccess2 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var acceptPartial = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.PartialSuccess);
|
||||
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = remediateSuccess1, SimilarityScore = 0.9 },
|
||||
new() { Record = remediateSuccess2, SimilarityScore = 0.85 },
|
||||
new() { Record = acceptPartial, SimilarityScore = 0.8 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
// Remediate should rank higher due to 100% success rate
|
||||
var firstSuggestion = result.Suggestions.First();
|
||||
firstSuggestion.Action.Should().Be(DecisionAction.Remediate);
|
||||
firstSuggestion.SuccessRate.Should().Be(1.0); // 100%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_RespectsMaxSuggestionsLimit()
|
||||
{
|
||||
// Arrange - more actions than max suggestions
|
||||
var remediate = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var accept = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.Success);
|
||||
var mitigate = CreatePastRecord(DecisionAction.Mitigate, OutcomeStatus.Success);
|
||||
var defer = CreatePastRecord(DecisionAction.Defer, OutcomeStatus.Success);
|
||||
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = remediate, SimilarityScore = 0.9 },
|
||||
new() { Record = accept, SimilarityScore = 0.85 },
|
||||
new() { Record = mitigate, SimilarityScore = 0.8 },
|
||||
new() { Record = defer, SimilarityScore = 0.75 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" },
|
||||
MaxSuggestions = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_IncludesEvidenceRecords()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Evidence.Should().NotBeEmpty();
|
||||
suggestion.Evidence.First().MemoryId.Should().Be(pastRecord.MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_CalculatesConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Confidence.Should().BeGreaterThan(0);
|
||||
suggestion.Confidence.Should().BeLessOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_GeneratesRationale()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Rationale.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreatePastRecord(DecisionAction action, OutcomeStatus outcome)
|
||||
{
|
||||
var memoryId = Guid.NewGuid().ToString("N");
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = "tenant-1",
|
||||
RecordedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-44487",
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = "Test decision rationale",
|
||||
DecidedBy = "test-user",
|
||||
DecidedAt = DateTimeOffset.UtcNow.AddDays(-7)
|
||||
},
|
||||
Outcome = new OutcomeRecord
|
||||
{
|
||||
Status = outcome,
|
||||
ResolutionTime = TimeSpan.FromHours(4),
|
||||
RecordedBy = "test-user",
|
||||
RecordedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user