consolidate the tests locations
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoringServiceTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Tasks: ISCORE-8200-018, ISCORE-8200-023, ISCORE-8200-028
|
||||
// Description: Integration tests for scoring service, job execution, and degradation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Interest.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="InterestScoringService"/>.
|
||||
/// Tests job execution, score consistency, and degradation/restoration cycles.
|
||||
/// </summary>
|
||||
public class InterestScoringServiceTests
|
||||
{
|
||||
private readonly Mock<IInterestScoreRepository> _repositoryMock;
|
||||
private readonly InterestScoringService _service;
|
||||
private readonly InterestScoreWeights _defaultWeights = new();
|
||||
|
||||
public InterestScoringServiceTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IInterestScoreRepository>();
|
||||
|
||||
var options = Options.Create(new InterestScoreOptions
|
||||
{
|
||||
DegradationPolicy = new StubDegradationPolicy
|
||||
{
|
||||
DegradationThreshold = 0.2,
|
||||
RestorationThreshold = 0.4,
|
||||
MinAgeDays = 30,
|
||||
BatchSize = 1000,
|
||||
Enabled = true
|
||||
},
|
||||
Job = new ScoringJobOptions
|
||||
{
|
||||
Enabled = true,
|
||||
FullRecalculationBatchSize = 100
|
||||
}
|
||||
});
|
||||
|
||||
_service = new InterestScoringService(
|
||||
_repositoryMock.Object,
|
||||
new InterestScoreCalculator(_defaultWeights),
|
||||
options,
|
||||
advisoryStore: null,
|
||||
cacheService: null,
|
||||
logger: NullLogger<InterestScoringService>.Instance);
|
||||
}
|
||||
|
||||
#region Task 18: Integration Tests - Score Persistence
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_PersistsToRepository()
|
||||
{
|
||||
// Arrange
|
||||
var score = CreateTestScore(0.75, ["in_sbom", "reachable"]);
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveAsync(score, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_RetrievesFromRepository()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var expected = CreateTestScore(0.5, ["in_sbom"], canonicalId);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expected);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.Score.Should().Be(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((InterestScore?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_UpdatesMultipleScores()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
|
||||
// Act
|
||||
await _service.BatchUpdateAsync(ids);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveManyAsync(It.Is<IEnumerable<InterestScore>>(s => s.Count() == 3), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_HandlesEmptyInput()
|
||||
{
|
||||
// Act
|
||||
await _service.BatchUpdateAsync([]);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveManyAsync(It.IsAny<IEnumerable<InterestScore>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 23: Job Execution and Score Consistency
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so RecalculateAllAsync returns 0 immediately
|
||||
// (which is correct behavior for tests without full integration setup)
|
||||
|
||||
// Act
|
||||
var result = await _service.RecalculateAllAsync();
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act - compute twice with same input
|
||||
var result1 = await _service.ComputeScoreAsync(canonicalId);
|
||||
var result2 = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert - same inputs should produce same outputs
|
||||
result1.Score.Should().Be(result2.Score);
|
||||
result1.Reasons.Should().BeEquivalentTo(result2.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_ReturnsValidScoreRange()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeInRange(0.0, 1.0);
|
||||
result.CanonicalId.Should().Be(canonicalId);
|
||||
result.ComputedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_PreservesScoreConsistency()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
InterestScore? savedScore = null;
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InterestScore, CancellationToken>((s, _) => savedScore = s)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var score = CreateTestScore(0.75, ["in_sbom", "reachable"], canonicalId);
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert
|
||||
savedScore.Should().NotBeNull();
|
||||
savedScore!.CanonicalId.Should().Be(canonicalId);
|
||||
savedScore.Score.Should().Be(0.75);
|
||||
savedScore.Reasons.Should().BeEquivalentTo(["in_sbom", "reachable"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_MaintainsScoreOrdering()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
IEnumerable<InterestScore>? savedScores = null;
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveManyAsync(It.IsAny<IEnumerable<InterestScore>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<InterestScore>, CancellationToken>((s, _) => savedScores = s.ToList())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.BatchUpdateAsync(ids);
|
||||
|
||||
// Assert
|
||||
savedScores.Should().NotBeNull();
|
||||
var scoreList = savedScores!.ToList();
|
||||
scoreList.Should().HaveCount(3);
|
||||
scoreList.Select(s => s.CanonicalId).Should().BeEquivalentTo(ids);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 28: Degradation/Restoration Cycle
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so degradation operations should return 0 immediately
|
||||
// (which is correct behavior for tests without full integration setup)
|
||||
|
||||
// Act
|
||||
var result = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so restoration operations should return 0 immediately
|
||||
|
||||
// Act
|
||||
var result = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeRestoreCycle_MaintainsDataIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var scores = new Dictionary<Guid, InterestScore>();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InterestScore, CancellationToken>((s, _) => scores[s.CanonicalId] = s)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => scores.GetValueOrDefault(canonicalId));
|
||||
|
||||
// Initial low score
|
||||
var lowScore = CreateTestScore(0.1, [], canonicalId);
|
||||
await _service.UpdateScoreAsync(lowScore);
|
||||
|
||||
// Verify low score stored
|
||||
var stored = await _service.GetScoreAsync(canonicalId);
|
||||
stored!.Score.Should().Be(0.1);
|
||||
|
||||
// Update to high score (simulating new evidence)
|
||||
var highScore = CreateTestScore(0.8, ["in_sbom", "reachable", "deployed"], canonicalId);
|
||||
await _service.UpdateScoreAsync(highScore);
|
||||
|
||||
// Verify high score stored
|
||||
stored = await _service.GetScoreAsync(canonicalId);
|
||||
stored!.Score.Should().Be(0.8);
|
||||
stored.Reasons.Should().Contain("in_sbom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_ReturnsZero_WhenNoLowScores()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetLowScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Guid>());
|
||||
|
||||
// Act
|
||||
var result = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_ReturnsZero_WhenNoHighScores()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetHighScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Guid>());
|
||||
|
||||
// Act
|
||||
var result = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_HandlesBoundaryScores()
|
||||
{
|
||||
// Arrange
|
||||
var minScore = CreateTestScore(0.0, []);
|
||||
var maxScore = CreateTestScore(1.0, ["in_sbom", "reachable", "deployed", "no_vex_na", "recent"]);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
await _service.UpdateScoreAsync(minScore);
|
||||
await _service.UpdateScoreAsync(maxScore);
|
||||
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_HandlesNullInputGracefully()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ComputeScoreAsync(Guid.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.CanonicalId.Should().Be(Guid.Empty);
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static InterestScore CreateTestScore(
|
||||
double score,
|
||||
string[] reasons,
|
||||
Guid? canonicalId = null)
|
||||
{
|
||||
return new InterestScore
|
||||
{
|
||||
CanonicalId = canonicalId ?? Guid.NewGuid(),
|
||||
Score = score,
|
||||
Reasons = reasons,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -9,11 +9,17 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Concelier.Interest.Tests</RootNamespace>
|
||||
<!-- Unit tests use mocks, no need for Postgres test infrastructure -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user