save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -3,8 +3,10 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using Xunit;
@@ -34,18 +36,6 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow);
private static GraphOverlayJob OverlayJob(string tenant, string id, GraphJobStatus status = GraphJobStatus.Pending)
=> new(
id: id,
tenantId: tenant,
graphSnapshotId: "snap-1",
status: status,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
targetGraphId: "graph-1",
correlationId: null,
metadata: null);
[Fact]
public async Task InsertAndGetBuildJob()
{
@@ -117,7 +107,7 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
private SchedulerDataSource CreateDataSource()
{
var options = _fixture.Fixture.CreateOptions();
options.SchemaName = _fixture.SchemaName;
return new SchedulerDataSource(Options.Create(options));
options.SchemaName = SchedulerDataSource.DefaultSchemaName;
return new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
}
}

View File

@@ -1,4 +1,5 @@
using System.Reflection;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scheduler.Storage.Postgres;
using Xunit;
@@ -15,6 +16,44 @@ public sealed class SchedulerPostgresFixture : PostgresIntegrationFixture, IColl
=> typeof(SchedulerDataSource).Assembly;
protected override string GetModuleName() => "Scheduler";
public new async Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
{
// Base fixture truncates the randomly-generated test schema (e.g. schema_migrations table lives there).
await Fixture.TruncateAllTablesAsync(cancellationToken).ConfigureAwait(false);
// Scheduler migrations create the canonical `scheduler.*` schema explicitly, so we must truncate it as well
// to ensure test isolation between methods.
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
const string listTablesSql = """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'scheduler'
AND table_type = 'BASE TABLE';
""";
var tables = new List<string>();
await using (var command = new NpgsqlCommand(listTablesSql, connection))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
}
if (tables.Count == 0)
{
return;
}
var qualified = tables.Select(static t => $"scheduler.\"{t}\"");
var truncateSql = $"TRUNCATE TABLE {string.Join(", ", qualified)} RESTART IDENTITY CASCADE;";
await using var truncateCommand = new NpgsqlCommand(truncateSql, connection);
await truncateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>

View File

@@ -12,17 +12,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Update="xunit" Version="2.9.2" />
<PackageReference Update="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Update="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,155 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scheduler.Storage.Postgres.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class FailureSignatureEndpointTests : IClassFixture<SchedulerWebApplicationFactory>
{
private readonly SchedulerWebApplicationFactory _factory;
public FailureSignatureEndpointTests(SchedulerWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task BestMatch_WhenMissing_ReturnsNoContent()
{
var repository = new StubFailureSignatureRepository(match: null);
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IFailureSignatureRepository>();
services.AddSingleton<IFailureSignatureRepository>(repository);
}));
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.NotNull(repository.LastCall);
Assert.Equal(FailureSignatureScopeType.Repo, repository.LastCall!.Value.ScopeType);
Assert.Equal("acme/repo", repository.LastCall!.Value.ScopeId);
Assert.Equal("tch_123", repository.LastCall!.Value.ToolchainHash);
}
[Fact]
public async Task BestMatch_WhenPresent_ReturnsPayload()
{
var signatureId = Guid.Parse("e22132b0-2aa7-4cde-94a9-0b335d321c61");
var firstSeen = new DateTimeOffset(2025, 12, 18, 10, 0, 0, TimeSpan.Zero);
var lastSeen = firstSeen.AddMinutes(5);
var signature = new FailureSignatureEntity
{
SignatureId = signatureId,
TenantId = "tenant-failure-signatures",
ScopeType = FailureSignatureScopeType.Repo,
ScopeId = "acme/repo",
ToolchainHash = "tch_123",
ErrorCode = "E123",
ErrorCategory = ErrorCategory.Network,
OccurrenceCount = 7,
FirstSeenAt = firstSeen,
LastSeenAt = lastSeen,
PredictedOutcome = PredictedOutcome.Fail,
ConfidenceScore = 0.85m
};
var repository = new StubFailureSignatureRepository(signature);
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IFailureSignatureRepository>();
services.AddSingleton<IFailureSignatureRepository>(repository);
}));
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(signatureId, payload.GetProperty("signatureId").GetGuid());
Assert.Equal("repo", payload.GetProperty("scopeType").GetString());
Assert.Equal("acme/repo", payload.GetProperty("scopeId").GetString());
Assert.Equal("tch_123", payload.GetProperty("toolchainHash").GetString());
Assert.Equal("E123", payload.GetProperty("errorCode").GetString());
Assert.Equal("network", payload.GetProperty("errorCategory").GetString());
Assert.Equal("fail", payload.GetProperty("predictedOutcome").GetString());
Assert.Equal(7, payload.GetProperty("occurrenceCount").GetInt32());
Assert.Equal(0.85m, payload.GetProperty("confidenceScore").GetDecimal());
Assert.Equal(firstSeen, payload.GetProperty("firstSeenAt").GetDateTimeOffset());
Assert.Equal(lastSeen, payload.GetProperty("lastSeenAt").GetDateTimeOffset());
}
private sealed class StubFailureSignatureRepository : IFailureSignatureRepository
{
private readonly FailureSignatureEntity? _match;
public StubFailureSignatureRepository(FailureSignatureEntity? match)
{
_match = match;
}
public (string TenantId, FailureSignatureScopeType ScopeType, string ScopeId, string? ToolchainHash)? LastCall { get; private set; }
public Task<FailureSignatureEntity> CreateAsync(FailureSignatureEntity signature, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetByIdAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetByKeyAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetByScopeAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetUnresolvedAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetByPredictedOutcomeAsync(string tenantId, PredictedOutcome outcome, decimal minConfidence = 0.5m, int limit = 100, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity> UpsertOccurrenceAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, ErrorCategory? errorCategory, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> UpdateResolutionAsync(string tenantId, Guid signatureId, ResolutionStatus status, string? notes, string? resolvedBy, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> UpdatePredictionAsync(string tenantId, Guid signatureId, PredictedOutcome outcome, decimal confidence, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> DeleteAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<int> PruneResolvedAsync(string tenantId, TimeSpan olderThan, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetBestMatchAsync(
string tenantId,
FailureSignatureScopeType scopeType,
string scopeId,
string? toolchainHash = null,
CancellationToken cancellationToken = default)
{
LastCall = (tenantId, scopeType, scopeId, toolchainHash);
return Task.FromResult(_match);
}
}
}