up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -9,4 +9,10 @@ namespace StellaOps.RiskEngine.Core.Services;
|
||||
public interface IRiskScoreResultStore
|
||||
{
|
||||
Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read a previously persisted result by job identifier.
|
||||
/// Implementations must be deterministic and side-effect free.
|
||||
/// </summary>
|
||||
bool TryGet(Guid jobId, out RiskScoreResult result);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ public sealed class RiskScoreQueue
|
||||
return channel.Writer.WriteAsync(job, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a request and returns the assigned job id for later retrieval.
|
||||
/// </summary>
|
||||
public async ValueTask<Guid> EnqueueWithIdAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
await channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job.JobId;
|
||||
}
|
||||
|
||||
public ValueTask<RiskScoreJob> DequeueAsync(CancellationToken cancellationToken) =>
|
||||
channel.Reader.ReadAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.RiskEngine.Tests;
|
||||
|
||||
public class RiskEngineApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> factory;
|
||||
|
||||
public RiskEngineApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
this.factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Providers_ListsDefaultTransforms()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var response = await client.GetAsync("/risk-scores/providers", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ProvidersResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Contains(DefaultTransformsProvider.ProviderName, payload!.Providers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_SubmitAndRetrieve_PersistsResult()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-1", new Dictionary<string, double>
|
||||
{
|
||||
["signal"] = 0.5
|
||||
});
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/risk-scores/jobs", request, ct);
|
||||
Assert.Equal(HttpStatusCode.Accepted, submit.StatusCode);
|
||||
|
||||
var accepted = await submit.Content.ReadFromJsonAsync<JobAccepted>(cancellationToken: ct);
|
||||
Assert.NotNull(accepted);
|
||||
Assert.True(accepted!.Result.Success);
|
||||
|
||||
var fetched = await client.GetFromJsonAsync<RiskScoreResult>($"/risk-scores/jobs/{accepted.JobId}", ct);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(accepted.JobId, fetched!.JobId);
|
||||
Assert.True(fetched.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simulations_ReturnsBatch()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-a", new Dictionary<string, double>{{"a", 0.2}}),
|
||||
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-b", new Dictionary<string, double>{{"b", 0.8}})
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.Results.Count);
|
||||
Assert.All(payload.Results, r => Assert.True(r.Success));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simulations_Summary_ReturnsAggregatesAndTopMovers()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-high", new Dictionary<string, double>{{"s1", 1.0}}),
|
||||
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-mid", new Dictionary<string, double>{{"s1", 0.5}}),
|
||||
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-low", new Dictionary<string, double>{{"s1", 0.2}})
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/risk-scores/simulations/summary", requests, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SimulationSummaryResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(3, payload!.Results.Count);
|
||||
Assert.All(payload.Results, r => Assert.True(r.Success));
|
||||
|
||||
Assert.Equal(0.566667, Math.Round(payload.Summary.AverageScore, 6));
|
||||
Assert.Equal(0.2, payload.Summary.MinScore);
|
||||
Assert.Equal(1.0, payload.Summary.MaxScore);
|
||||
|
||||
Assert.Equal(3, payload.Summary.TopMovers.Count);
|
||||
Assert.Collection(payload.Summary.TopMovers,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("asset-high", first.Subject);
|
||||
Assert.Equal(1.0, first.Score);
|
||||
},
|
||||
second => Assert.Equal("asset-mid", second.Subject),
|
||||
third => Assert.Equal("asset-low", third.Subject));
|
||||
}
|
||||
|
||||
private sealed record ProvidersResponse(IReadOnlyList<string> Providers);
|
||||
private sealed record JobAccepted(Guid JobId, RiskScoreResult Result);
|
||||
private sealed record SimulationResponse(IReadOnlyList<RiskScoreResult> Results);
|
||||
private sealed record SimulationSummaryDto(double AverageScore, double MinScore, double MaxScore, IReadOnlyList<RiskScoreResult> TopMovers);
|
||||
private sealed record SimulationSummaryResponse(SimulationSummaryDto Summary, IReadOnlyList<RiskScoreResult> Results);
|
||||
}
|
||||
@@ -58,17 +58,10 @@
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107"/>
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
@@ -116,12 +109,9 @@
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.RiskEngine.Infrastructure\StellaOps.RiskEngine.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.RiskEngine.Infrastructure\StellaOps.RiskEngine.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.RiskEngine.WebService\StellaOps.RiskEngine.WebService.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Linq;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
using StellaOps.RiskEngine.Core.Services;
|
||||
@@ -32,51 +34,92 @@ app.MapGet("/risk-scores/providers", (IRiskScoreProviderRegistry registry) =>
|
||||
|
||||
app.MapPost("/risk-scores/jobs", async (
|
||||
ScoreRequest request,
|
||||
RiskScoreQueue queue,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
IRiskScoreResultStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] RiskScoreQueue queue,
|
||||
[FromServices] IRiskScoreProviderRegistry registry,
|
||||
[FromServices] IRiskScoreResultStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
await queue.EnqueueAsync(job.Request, ct).ConfigureAwait(false);
|
||||
var worker = new RiskScoreWorker(queue, registry, store, timeProvider);
|
||||
var normalized = new ScoreRequest(
|
||||
request.Provider,
|
||||
request.Subject,
|
||||
request.Signals ?? new Dictionary<string, double>());
|
||||
|
||||
var jobId = await queue.EnqueueWithIdAsync(normalized, ct).ConfigureAwait(false);
|
||||
var worker = new RiskScoreWorker(queue, registry, store, TimeProvider.System);
|
||||
var result = await worker.ProcessNextAsync(ct).ConfigureAwait(false);
|
||||
return Results.Accepted($"/risk-scores/jobs/{job.JobId}", new { jobId = job.JobId, result });
|
||||
return Results.Accepted($"/risk-scores/jobs/{jobId}", new { jobId, result });
|
||||
});
|
||||
|
||||
app.MapGet("/risk-scores/jobs/{jobId:guid}", (Guid jobId, InMemoryRiskScoreResultStore store) =>
|
||||
app.MapGet("/risk-scores/jobs/{jobId:guid}", (
|
||||
Guid jobId,
|
||||
[FromServices] IRiskScoreResultStore store) =>
|
||||
store.TryGet(jobId, out var result)
|
||||
? Results.Ok(result)
|
||||
: Results.NotFound());
|
||||
|
||||
app.MapPost("/risk-scores/simulations", async (
|
||||
IReadOnlyCollection<ScoreRequest> requests,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] IRiskScoreProviderRegistry registry,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
|
||||
return Results.Ok(new { results });
|
||||
});
|
||||
|
||||
app.MapPost("/risk-scores/simulations/summary", async (
|
||||
IReadOnlyCollection<ScoreRequest> requests,
|
||||
[FromServices] IRiskScoreProviderRegistry registry,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
|
||||
|
||||
var scores = results.Select(r => r.Score).ToArray();
|
||||
var summary = new
|
||||
{
|
||||
averageScore = scores.Length == 0 ? 0d : scores.Average(),
|
||||
minScore = scores.Length == 0 ? 0d : scores.Min(),
|
||||
maxScore = scores.Length == 0 ? 0d : scores.Max(),
|
||||
topMovers = results
|
||||
.OrderByDescending(r => r.Score)
|
||||
.ThenBy(r => r.Subject, StringComparer.Ordinal)
|
||||
.Take(3)
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
return Results.Ok(new { summary, results });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<List<RiskScoreResult>> EvaluateAsync(
|
||||
IReadOnlyCollection<ScoreRequest> requests,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = new List<RiskScoreResult>(requests.Count);
|
||||
foreach (var req in requests)
|
||||
{
|
||||
if (!registry.TryGet(req.Provider, out var provider))
|
||||
var normalized = new ScoreRequest(
|
||||
req.Provider,
|
||||
req.Subject,
|
||||
req.Signals ?? new Dictionary<string, double>());
|
||||
|
||||
if (!registry.TryGet(normalized.Provider, out var provider))
|
||||
{
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, "Provider not registered", req.Signals, timeProvider.GetUtcNow()));
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, "Provider not registered", normalized.Signals, TimeProvider.System.GetUtcNow()));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var score = await provider.ScoreAsync(req, ct).ConfigureAwait(false);
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, score, true, null, req.Signals, timeProvider.GetUtcNow()));
|
||||
var score = await provider.ScoreAsync(normalized, ct).ConfigureAwait(false);
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, score, true, null, normalized.Signals, TimeProvider.System.GetUtcNow()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, ex.Message, req.Signals, timeProvider.GetUtcNow()));
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, ex.Message, normalized.Signals, TimeProvider.System.GetUtcNow()));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new { results });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
return results;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user