feat: Add Go module and workspace test fixtures

- Created expected JSON files for Go modules and workspaces.
- Added go.mod and go.sum files for example projects.
- Implemented private module structure with expected JSON output.
- Introduced vendored dependencies with corresponding expected JSON.
- Developed PostgresGraphJobStore for managing graph jobs.
- Established SQL migration scripts for graph jobs schema.
- Implemented GraphJobRepository for CRUD operations on graph jobs.
- Created IGraphJobRepository interface for repository abstraction.
- Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
StellaOps Bot
2025-12-06 20:04:03 +02:00
parent a6f1406509
commit 05597616d6
178 changed files with 12022 additions and 4545 deletions

View File

@@ -1,12 +1,12 @@
global using System.Text.Json;
global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Logging.Abstractions;
global using Microsoft.Extensions.Options;
global using Mongo2Go;
global using MongoDB.Bson;
global using MongoDB.Driver;
global using StellaOps.Scheduler.Models;
global using StellaOps.Scheduler.Storage.Mongo.Internal;
global using StellaOps.Scheduler.Storage.Mongo.Migrations;
global using StellaOps.Scheduler.Storage.Mongo.Options;
global using Xunit;
global using System.Text.Json;
global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Logging.Abstractions;
global using Microsoft.Extensions.Options;
global using Mongo2Go;
global using MongoDB.Bson;
global using MongoDB.Driver;
global using StellaOps.Scheduler.Models;
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Internal;
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Migrations;
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Options;
global using Xunit;

View File

@@ -1,70 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.WebService.GraphJobs;
using Xunit;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration;
public sealed class GraphJobStoreTests
{
private static readonly DateTimeOffset OccurredAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
[Fact]
public async Task UpdateAsync_SucceedsWhenExpectedStatusMatches()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new GraphJobRepository(harness.Context);
var store = new MongoGraphJobStore(repository);
var initial = CreateBuildJob();
await store.AddAsync(initial, CancellationToken.None);
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
var updateResult = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
Assert.True(updateResult.Updated);
var persisted = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None);
Assert.NotNull(persisted);
Assert.Equal(GraphJobStatus.Completed, persisted!.Status);
}
[Fact]
public async Task UpdateAsync_ReturnsExistingWhenExpectedStatusMismatch()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new GraphJobRepository(harness.Context);
var store = new MongoGraphJobStore(repository);
var initial = CreateBuildJob();
await store.AddAsync(initial, CancellationToken.None);
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
var result = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
Assert.False(result.Updated);
Assert.Equal(GraphJobStatus.Completed, result.Job.Status);
}
private static GraphBuildJob CreateBuildJob()
{
var digest = "sha256:" + new string('b', 64);
return new GraphBuildJob(
id: "gbj_store_test",
tenantId: "tenant-store",
sbomId: "sbom-alpha",
sbomVersionId: "sbom-alpha-v1",
sbomDigest: digest,
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: OccurredAt,
metadata: null);
}
}
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.WebService.GraphJobs;
using Xunit;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Integration;
public sealed class GraphJobStoreTests
{
private static readonly DateTimeOffset OccurredAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
[Fact]
public async Task UpdateAsync_SucceedsWhenExpectedStatusMatches()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new GraphJobRepository(harness.Context);
var store = new MongoGraphJobStore(repository);
var initial = CreateBuildJob();
await store.AddAsync(initial, CancellationToken.None);
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
var updateResult = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
Assert.True(updateResult.Updated);
var persisted = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None);
Assert.NotNull(persisted);
Assert.Equal(GraphJobStatus.Completed, persisted!.Status);
}
[Fact]
public async Task UpdateAsync_ReturnsExistingWhenExpectedStatusMismatch()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new GraphJobRepository(harness.Context);
var store = new MongoGraphJobStore(repository);
var initial = CreateBuildJob();
await store.AddAsync(initial, CancellationToken.None);
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
var result = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
Assert.False(result.Updated);
Assert.Equal(GraphJobStatus.Completed, result.Job.Status);
}
private static GraphBuildJob CreateBuildJob()
{
var digest = "sha256:" + new string('b', 64);
return new GraphBuildJob(
id: "gbj_store_test",
tenantId: "tenant-store",
sbomId: "sbom-alpha",
sbomVersionId: "sbom-alpha-v1",
sbomDigest: digest,
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: OccurredAt,
metadata: null);
}
}

View File

@@ -1,126 +1,126 @@
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration;
public sealed class SchedulerMongoRoundTripTests : IDisposable
{
private readonly MongoDbRunner _runner;
private readonly SchedulerMongoContext _context;
public SchedulerMongoRoundTripTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_roundtrip_{Guid.NewGuid():N}"
};
_context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(_context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
[Fact]
public async Task SamplesRoundTripThroughMongoWithoutLosingCanonicalShape()
{
var samplesRoot = LocateSamplesRoot();
var scheduleJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "schedule.json"), CancellationToken.None);
await AssertRoundTripAsync(
scheduleJson,
_context.Options.SchedulesCollection,
CanonicalJsonSerializer.Deserialize<Schedule>,
schedule => schedule.Id);
var runJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "run.json"), CancellationToken.None);
await AssertRoundTripAsync(
runJson,
_context.Options.RunsCollection,
CanonicalJsonSerializer.Deserialize<Run>,
run => run.Id);
var impactJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "impact-set.json"), CancellationToken.None);
await AssertRoundTripAsync(
impactJson,
_context.Options.ImpactSnapshotsCollection,
CanonicalJsonSerializer.Deserialize<ImpactSet>,
_ => null);
var auditJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "audit.json"), CancellationToken.None);
await AssertRoundTripAsync(
auditJson,
_context.Options.AuditCollection,
CanonicalJsonSerializer.Deserialize<AuditRecord>,
audit => audit.Id);
}
private async Task AssertRoundTripAsync<TModel>(
string json,
string collectionName,
Func<string, TModel> deserialize,
Func<TModel, string?> resolveId)
{
ArgumentNullException.ThrowIfNull(deserialize);
ArgumentNullException.ThrowIfNull(resolveId);
var model = deserialize(json);
var canonical = CanonicalJsonSerializer.Serialize(model);
var document = BsonDocument.Parse(canonical);
var identifier = resolveId(model);
if (!string.IsNullOrEmpty(identifier))
{
document["_id"] = identifier;
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
await collection.InsertOneAsync(document, cancellationToken: CancellationToken.None);
var filter = identifier is null ? Builders<BsonDocument>.Filter.Empty : Builders<BsonDocument>.Filter.Eq("_id", identifier);
var stored = await collection.Find(filter).FirstOrDefaultAsync();
Assert.NotNull(stored);
var sanitized = stored!.DeepClone().AsBsonDocument;
sanitized.Remove("_id");
var storedJson = sanitized.ToJson();
var parsedExpected = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical node null.");
var parsedActual = JsonNode.Parse(storedJson) ?? throw new InvalidOperationException("Stored node null.");
Assert.True(JsonNode.DeepEquals(parsedExpected, parsedActual), "Document changed shape after Mongo round-trip.");
}
private static string LocateSamplesRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "samples", "api", "scheduler");
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.Equals(parent, current, StringComparison.Ordinal))
{
break;
}
current = parent;
}
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
}
public void Dispose()
{
_runner.Dispose();
}
}
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Integration;
public sealed class SchedulerMongoRoundTripTests : IDisposable
{
private readonly MongoDbRunner _runner;
private readonly SchedulerMongoContext _context;
public SchedulerMongoRoundTripTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_roundtrip_{Guid.NewGuid():N}"
};
_context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(_context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
[Fact]
public async Task SamplesRoundTripThroughMongoWithoutLosingCanonicalShape()
{
var samplesRoot = LocateSamplesRoot();
var scheduleJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "schedule.json"), CancellationToken.None);
await AssertRoundTripAsync(
scheduleJson,
_context.Options.SchedulesCollection,
CanonicalJsonSerializer.Deserialize<Schedule>,
schedule => schedule.Id);
var runJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "run.json"), CancellationToken.None);
await AssertRoundTripAsync(
runJson,
_context.Options.RunsCollection,
CanonicalJsonSerializer.Deserialize<Run>,
run => run.Id);
var impactJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "impact-set.json"), CancellationToken.None);
await AssertRoundTripAsync(
impactJson,
_context.Options.ImpactSnapshotsCollection,
CanonicalJsonSerializer.Deserialize<ImpactSet>,
_ => null);
var auditJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "audit.json"), CancellationToken.None);
await AssertRoundTripAsync(
auditJson,
_context.Options.AuditCollection,
CanonicalJsonSerializer.Deserialize<AuditRecord>,
audit => audit.Id);
}
private async Task AssertRoundTripAsync<TModel>(
string json,
string collectionName,
Func<string, TModel> deserialize,
Func<TModel, string?> resolveId)
{
ArgumentNullException.ThrowIfNull(deserialize);
ArgumentNullException.ThrowIfNull(resolveId);
var model = deserialize(json);
var canonical = CanonicalJsonSerializer.Serialize(model);
var document = BsonDocument.Parse(canonical);
var identifier = resolveId(model);
if (!string.IsNullOrEmpty(identifier))
{
document["_id"] = identifier;
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
await collection.InsertOneAsync(document, cancellationToken: CancellationToken.None);
var filter = identifier is null ? Builders<BsonDocument>.Filter.Empty : Builders<BsonDocument>.Filter.Eq("_id", identifier);
var stored = await collection.Find(filter).FirstOrDefaultAsync();
Assert.NotNull(stored);
var sanitized = stored!.DeepClone().AsBsonDocument;
sanitized.Remove("_id");
var storedJson = sanitized.ToJson();
var parsedExpected = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical node null.");
var parsedActual = JsonNode.Parse(storedJson) ?? throw new InvalidOperationException("Stored node null.");
Assert.True(JsonNode.DeepEquals(parsedExpected, parsedActual), "Document changed shape after Mongo round-trip.");
}
private static string LocateSamplesRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "samples", "api", "scheduler");
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.Equals(parent, current, StringComparison.Ordinal))
{
break;
}
current = parent;
}
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
}
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -1,106 +1,106 @@
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Migrations;
public sealed class SchedulerMongoMigrationTests : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoMigrationTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
}
[Fact]
public async Task RunAsync_CreatesCollectionsAndIndexes()
{
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
var context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
await runner.RunAsync(CancellationToken.None);
var cursor = await context.Database.ListCollectionNamesAsync(cancellationToken: CancellationToken.None);
var collections = await cursor.ToListAsync();
Assert.Contains(options.SchedulesCollection, collections);
Assert.Contains(options.RunsCollection, collections);
Assert.Contains(options.ImpactSnapshotsCollection, collections);
Assert.Contains(options.AuditCollection, collections);
Assert.Contains(options.LocksCollection, collections);
Assert.Contains(options.MigrationsCollection, collections);
await AssertScheduleIndexesAsync(context, options);
await AssertRunIndexesAsync(context, options);
await AssertImpactSnapshotIndexesAsync(context, options);
await AssertAuditIndexesAsync(context, options);
await AssertLockIndexesAsync(context, options);
}
private static async Task AssertScheduleIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.SchedulesCollection));
Assert.Contains("tenant_enabled", names);
Assert.Contains("cron_timezone", names);
}
private static async Task AssertRunIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var collection = context.Database.GetCollection<BsonDocument>(options.RunsCollection);
var indexes = await ListIndexesAsync(collection);
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "tenant_createdAt_desc", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "state_lookup", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "schedule_createdAt_desc", StringComparison.Ordinal));
var ttl = indexes.FirstOrDefault(doc => doc.TryGetValue("name", out var name) && name == "finishedAt_ttl");
Assert.NotNull(ttl);
Assert.Equal(options.CompletedRunRetention.TotalSeconds, ttl!["expireAfterSeconds"].ToDouble());
}
private static async Task AssertImpactSnapshotIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.ImpactSnapshotsCollection));
Assert.Contains("selector_tenant_scope", names);
Assert.Contains("snapshotId_unique", names);
}
private static async Task AssertAuditIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.AuditCollection));
Assert.Contains("tenant_occurredAt_desc", names);
Assert.Contains("correlation_lookup", names);
}
private static async Task AssertLockIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.LocksCollection));
Assert.Contains("tenant_resource_unique", names);
Assert.Contains("expiresAt_ttl", names);
}
private static async Task<IReadOnlyList<string>> ListIndexNamesAsync(IMongoCollection<BsonDocument> collection)
{
var documents = await ListIndexesAsync(collection);
return documents.Select(doc => doc["name"].AsString).ToArray();
}
private static async Task<List<BsonDocument>> ListIndexesAsync(IMongoCollection<BsonDocument> collection)
{
using var cursor = await collection.Indexes.ListAsync();
return await cursor.ToListAsync();
}
public void Dispose()
{
_runner.Dispose();
}
}
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Migrations;
public sealed class SchedulerMongoMigrationTests : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoMigrationTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
}
[Fact]
public async Task RunAsync_CreatesCollectionsAndIndexes()
{
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
var context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
await runner.RunAsync(CancellationToken.None);
var cursor = await context.Database.ListCollectionNamesAsync(cancellationToken: CancellationToken.None);
var collections = await cursor.ToListAsync();
Assert.Contains(options.SchedulesCollection, collections);
Assert.Contains(options.RunsCollection, collections);
Assert.Contains(options.ImpactSnapshotsCollection, collections);
Assert.Contains(options.AuditCollection, collections);
Assert.Contains(options.LocksCollection, collections);
Assert.Contains(options.MigrationsCollection, collections);
await AssertScheduleIndexesAsync(context, options);
await AssertRunIndexesAsync(context, options);
await AssertImpactSnapshotIndexesAsync(context, options);
await AssertAuditIndexesAsync(context, options);
await AssertLockIndexesAsync(context, options);
}
private static async Task AssertScheduleIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.SchedulesCollection));
Assert.Contains("tenant_enabled", names);
Assert.Contains("cron_timezone", names);
}
private static async Task AssertRunIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var collection = context.Database.GetCollection<BsonDocument>(options.RunsCollection);
var indexes = await ListIndexesAsync(collection);
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "tenant_createdAt_desc", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "state_lookup", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "schedule_createdAt_desc", StringComparison.Ordinal));
var ttl = indexes.FirstOrDefault(doc => doc.TryGetValue("name", out var name) && name == "finishedAt_ttl");
Assert.NotNull(ttl);
Assert.Equal(options.CompletedRunRetention.TotalSeconds, ttl!["expireAfterSeconds"].ToDouble());
}
private static async Task AssertImpactSnapshotIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.ImpactSnapshotsCollection));
Assert.Contains("selector_tenant_scope", names);
Assert.Contains("snapshotId_unique", names);
}
private static async Task AssertAuditIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.AuditCollection));
Assert.Contains("tenant_occurredAt_desc", names);
Assert.Contains("correlation_lookup", names);
}
private static async Task AssertLockIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.LocksCollection));
Assert.Contains("tenant_resource_unique", names);
Assert.Contains("expiresAt_ttl", names);
}
private static async Task<IReadOnlyList<string>> ListIndexNamesAsync(IMongoCollection<BsonDocument> collection)
{
var documents = await ListIndexesAsync(collection);
return documents.Select(doc => doc["name"].AsString).ToArray();
}
private static async Task<List<BsonDocument>> ListIndexesAsync(IMongoCollection<BsonDocument> collection)
{
using var cursor = await collection.Indexes.ListAsync();
return await cursor.ToListAsync();
}
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -1,60 +1,60 @@
using System;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class AuditRepositoryTests
{
[Fact]
public async Task InsertAndListAsync_ReturnsTenantScopedEntries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var record1 = TestDataFactory.CreateAuditRecord("tenant-alpha", "1");
var record2 = TestDataFactory.CreateAuditRecord("tenant-alpha", "2");
var otherTenant = TestDataFactory.CreateAuditRecord("tenant-beta", "3");
await repository.InsertAsync(record1);
await repository.InsertAsync(record2);
await repository.InsertAsync(otherTenant);
var results = await repository.ListAsync("tenant-alpha");
Assert.Equal(2, results.Count);
Assert.DoesNotContain(results, record => record.TenantId == "tenant-beta");
}
[Fact]
public async Task ListAsync_AppliesFilters()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var older = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"old",
occurredAt: DateTimeOffset.UtcNow.AddMinutes(-30),
scheduleId: "sch-a");
var newer = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"new",
occurredAt: DateTimeOffset.UtcNow,
scheduleId: "sch-a");
await repository.InsertAsync(older);
await repository.InsertAsync(newer);
var options = new AuditQueryOptions
{
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
ScheduleId = "sch-a",
Limit = 5
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("audit_new", results.Single().Id);
}
}
using System;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
public sealed class AuditRepositoryTests
{
[Fact]
public async Task InsertAndListAsync_ReturnsTenantScopedEntries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var record1 = TestDataFactory.CreateAuditRecord("tenant-alpha", "1");
var record2 = TestDataFactory.CreateAuditRecord("tenant-alpha", "2");
var otherTenant = TestDataFactory.CreateAuditRecord("tenant-beta", "3");
await repository.InsertAsync(record1);
await repository.InsertAsync(record2);
await repository.InsertAsync(otherTenant);
var results = await repository.ListAsync("tenant-alpha");
Assert.Equal(2, results.Count);
Assert.DoesNotContain(results, record => record.TenantId == "tenant-beta");
}
[Fact]
public async Task ListAsync_AppliesFilters()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var older = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"old",
occurredAt: DateTimeOffset.UtcNow.AddMinutes(-30),
scheduleId: "sch-a");
var newer = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"new",
occurredAt: DateTimeOffset.UtcNow,
scheduleId: "sch-a");
await repository.InsertAsync(older);
await repository.InsertAsync(newer);
var options = new AuditQueryOptions
{
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
ScheduleId = "sch-a",
Limit = 5
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("audit_new", results.Single().Id);
}
}

View File

@@ -1,41 +1,41 @@
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class ImpactSnapshotRepositoryTests
{
[Fact]
public async Task UpsertAndGetAsync_RoundTripsSnapshot()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var snapshot = TestDataFactory.CreateImpactSet("tenant-alpha", "impact-1", DateTimeOffset.UtcNow.AddMinutes(-5));
await repository.UpsertAsync(snapshot, cancellationToken: CancellationToken.None);
var stored = await repository.GetBySnapshotIdAsync("impact-1", cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(snapshot.SnapshotId, stored!.SnapshotId);
Assert.Equal(snapshot.Images[0].ImageDigest, stored.Images[0].ImageDigest);
}
[Fact]
public async Task GetLatestBySelectorAsync_ReturnsMostRecent()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var selectorTenant = "tenant-alpha";
var first = TestDataFactory.CreateImpactSet(selectorTenant, "impact-old", DateTimeOffset.UtcNow.AddMinutes(-10));
var latest = TestDataFactory.CreateImpactSet(selectorTenant, "impact-new", DateTimeOffset.UtcNow);
await repository.UpsertAsync(first);
await repository.UpsertAsync(latest);
var resolved = await repository.GetLatestBySelectorAsync(latest.Selector);
Assert.NotNull(resolved);
Assert.Equal("impact-new", resolved!.SnapshotId);
}
}
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
public sealed class ImpactSnapshotRepositoryTests
{
[Fact]
public async Task UpsertAndGetAsync_RoundTripsSnapshot()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var snapshot = TestDataFactory.CreateImpactSet("tenant-alpha", "impact-1", DateTimeOffset.UtcNow.AddMinutes(-5));
await repository.UpsertAsync(snapshot, cancellationToken: CancellationToken.None);
var stored = await repository.GetBySnapshotIdAsync("impact-1", cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(snapshot.SnapshotId, stored!.SnapshotId);
Assert.Equal(snapshot.Images[0].ImageDigest, stored.Images[0].ImageDigest);
}
[Fact]
public async Task GetLatestBySelectorAsync_ReturnsMostRecent()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var selectorTenant = "tenant-alpha";
var first = TestDataFactory.CreateImpactSet(selectorTenant, "impact-old", DateTimeOffset.UtcNow.AddMinutes(-10));
var latest = TestDataFactory.CreateImpactSet(selectorTenant, "impact-new", DateTimeOffset.UtcNow);
await repository.UpsertAsync(first);
await repository.UpsertAsync(latest);
var resolved = await repository.GetLatestBySelectorAsync(latest.Selector);
Assert.NotNull(resolved);
Assert.Equal("impact-new", resolved!.SnapshotId);
}
}

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class RunRepositoryTests
{
[Fact]
public async Task InsertAndGetAsync_RoundTripsRun()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_1", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(run.TenantId, run.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(run.State, stored!.State);
Assert.Equal(run.Trigger, stored.Trigger);
}
[Fact]
public async Task UpdateAsync_ChangesStateAndStats()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_update", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run);
var updated = run with
{
State = RunState.Completed,
FinishedAt = DateTimeOffset.UtcNow,
Stats = new RunStats(candidates: 10, deduped: 10, queued: 10, completed: 10, deltas: 2)
};
var result = await repository.UpdateAsync(updated);
Assert.True(result);
var stored = await repository.GetAsync(updated.TenantId, updated.Id);
Assert.NotNull(stored);
Assert.Equal(RunState.Completed, stored!.State);
Assert.Equal(10, stored.Stats.Completed);
}
[Fact]
public async Task ListAsync_FiltersByStateAndSchedule()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run1 = TestDataFactory.CreateRun("run_state_1", "tenant-alpha", RunState.Planning, scheduleId: "sch_a");
var run2 = TestDataFactory.CreateRun("run_state_2", "tenant-alpha", RunState.Running, scheduleId: "sch_a");
var run3 = TestDataFactory.CreateRun("run_state_3", "tenant-alpha", RunState.Completed, scheduleId: "sch_b");
await repository.InsertAsync(run1);
await repository.InsertAsync(run2);
await repository.InsertAsync(run3);
var options = new RunQueryOptions
{
ScheduleId = "sch_a",
States = new[] { RunState.Running }.ToImmutableArray(),
Limit = 10
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("run_state_2", results.Single().Id);
}
}
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
public sealed class RunRepositoryTests
{
[Fact]
public async Task InsertAndGetAsync_RoundTripsRun()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_1", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(run.TenantId, run.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(run.State, stored!.State);
Assert.Equal(run.Trigger, stored.Trigger);
}
[Fact]
public async Task UpdateAsync_ChangesStateAndStats()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_update", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run);
var updated = run with
{
State = RunState.Completed,
FinishedAt = DateTimeOffset.UtcNow,
Stats = new RunStats(candidates: 10, deduped: 10, queued: 10, completed: 10, deltas: 2)
};
var result = await repository.UpdateAsync(updated);
Assert.True(result);
var stored = await repository.GetAsync(updated.TenantId, updated.Id);
Assert.NotNull(stored);
Assert.Equal(RunState.Completed, stored!.State);
Assert.Equal(10, stored.Stats.Completed);
}
[Fact]
public async Task ListAsync_FiltersByStateAndSchedule()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run1 = TestDataFactory.CreateRun("run_state_1", "tenant-alpha", RunState.Planning, scheduleId: "sch_a");
var run2 = TestDataFactory.CreateRun("run_state_2", "tenant-alpha", RunState.Running, scheduleId: "sch_a");
var run3 = TestDataFactory.CreateRun("run_state_3", "tenant-alpha", RunState.Completed, scheduleId: "sch_b");
await repository.InsertAsync(run1);
await repository.InsertAsync(run2);
await repository.InsertAsync(run3);
var options = new RunQueryOptions
{
ScheduleId = "sch_a",
States = new[] { RunState.Running }.ToImmutableArray(),
Limit = 10
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("run_state_2", results.Single().Id);
}
}

View File

@@ -1,74 +1,74 @@
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class ScheduleRepositoryTests
{
[Fact]
public async Task UpsertAsync_PersistsScheduleWithCanonicalShape()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_unit_1", "tenant-alpha");
await repository.UpsertAsync(schedule, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(schedule.TenantId, schedule.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(schedule.Id, stored!.Id);
Assert.Equal(schedule.Name, stored.Name);
Assert.Equal(schedule.Selection.Scope, stored.Selection.Scope);
}
[Fact]
public async Task ListAsync_ExcludesDisabledAndDeletedByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var tenantId = "tenant-alpha";
var enabled = TestDataFactory.CreateSchedule("sch_enabled", tenantId, enabled: true, name: "Enabled");
var disabled = TestDataFactory.CreateSchedule("sch_disabled", tenantId, enabled: false, name: "Disabled");
await repository.UpsertAsync(enabled);
await repository.UpsertAsync(disabled);
await repository.SoftDeleteAsync(tenantId, enabled.Id, "svc_scheduler", DateTimeOffset.UtcNow);
var results = await repository.ListAsync(tenantId);
Assert.Empty(results);
var includeDisabled = await repository.ListAsync(
tenantId,
new ScheduleQueryOptions { IncludeDisabled = true, IncludeDeleted = true });
Assert.Equal(2, includeDisabled.Count);
Assert.Contains(includeDisabled, schedule => schedule.Id == enabled.Id);
Assert.Contains(includeDisabled, schedule => schedule.Id == disabled.Id);
}
[Fact]
public async Task SoftDeleteAsync_SetsMetadataAndExcludesFromQueries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_delete", "tenant-beta");
await repository.UpsertAsync(schedule);
var deletedAt = DateTimeOffset.UtcNow;
var deleted = await repository.SoftDeleteAsync(schedule.TenantId, schedule.Id, "svc_delete", deletedAt);
Assert.True(deleted);
var retrieved = await repository.GetAsync(schedule.TenantId, schedule.Id);
Assert.Null(retrieved);
var includeDeleted = await repository.ListAsync(
schedule.TenantId,
new ScheduleQueryOptions { IncludeDeleted = true, IncludeDisabled = true });
Assert.Single(includeDeleted);
Assert.Equal("sch_delete", includeDeleted[0].Id);
}
}
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
public sealed class ScheduleRepositoryTests
{
[Fact]
public async Task UpsertAsync_PersistsScheduleWithCanonicalShape()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_unit_1", "tenant-alpha");
await repository.UpsertAsync(schedule, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(schedule.TenantId, schedule.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(schedule.Id, stored!.Id);
Assert.Equal(schedule.Name, stored.Name);
Assert.Equal(schedule.Selection.Scope, stored.Selection.Scope);
}
[Fact]
public async Task ListAsync_ExcludesDisabledAndDeletedByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var tenantId = "tenant-alpha";
var enabled = TestDataFactory.CreateSchedule("sch_enabled", tenantId, enabled: true, name: "Enabled");
var disabled = TestDataFactory.CreateSchedule("sch_disabled", tenantId, enabled: false, name: "Disabled");
await repository.UpsertAsync(enabled);
await repository.UpsertAsync(disabled);
await repository.SoftDeleteAsync(tenantId, enabled.Id, "svc_scheduler", DateTimeOffset.UtcNow);
var results = await repository.ListAsync(tenantId);
Assert.Empty(results);
var includeDisabled = await repository.ListAsync(
tenantId,
new ScheduleQueryOptions { IncludeDisabled = true, IncludeDeleted = true });
Assert.Equal(2, includeDisabled.Count);
Assert.Contains(includeDisabled, schedule => schedule.Id == enabled.Id);
Assert.Contains(includeDisabled, schedule => schedule.Id == disabled.Id);
}
[Fact]
public async Task SoftDeleteAsync_SetsMetadataAndExcludesFromQueries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_delete", "tenant-beta");
await repository.UpsertAsync(schedule);
var deletedAt = DateTimeOffset.UtcNow;
var deleted = await repository.SoftDeleteAsync(schedule.TenantId, schedule.Id, "svc_delete", deletedAt);
Assert.True(deleted);
var retrieved = await repository.GetAsync(schedule.TenantId, schedule.Id);
Assert.Null(retrieved);
var includeDeleted = await repository.ListAsync(
schedule.TenantId,
new ScheduleQueryOptions { IncludeDeleted = true, IncludeDisabled = true });
Assert.Single(includeDeleted);
Assert.Equal("sch_delete", includeDeleted[0].Id);
}
}

View File

@@ -1,36 +1,36 @@
using System;
using System.Threading;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
internal sealed class SchedulerMongoTestHarness : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoTestHarness()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
Context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(Context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
public SchedulerMongoContext Context { get; }
public void Dispose()
{
_runner.Dispose();
}
}
using System;
using System.Threading;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests;
internal sealed class SchedulerMongoTestHarness : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoTestHarness()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
Context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(Context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
public SchedulerMongoContext Context { get; }
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -1,116 +1,116 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
public sealed class RunSummaryServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly RunSummaryRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly RunSummaryService _service;
public RunSummaryServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new RunSummaryRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T10:00:00Z"));
_service = new RunSummaryService(_repository, _timeProvider, NullLogger<RunSummaryService>.Instance);
}
[Fact]
public async Task ProjectAsync_FirstRunCreatesProjection()
{
var run = TestDataFactory.CreateRun("run-1", "tenant-alpha", RunState.Planning, "sch-alpha");
var projection = await _service.ProjectAsync(run, CancellationToken.None);
Assert.Equal("tenant-alpha", projection.TenantId);
Assert.Equal("sch-alpha", projection.ScheduleId);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Planning, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Total);
Assert.Equal(1, projection.Counters.Planning);
Assert.Equal(0, projection.Counters.Completed);
Assert.Single(projection.Recent);
Assert.Equal(run.Id, projection.Recent[0].RunId);
}
[Fact]
public async Task ProjectAsync_UpdateRunReplacesExistingEntry()
{
var createdAt = DateTimeOffset.Parse("2025-10-26T09:55:00Z");
var run = TestDataFactory.CreateRun(
"run-update",
"tenant-alpha",
RunState.Planning,
"sch-alpha",
createdAt: createdAt,
startedAt: createdAt.AddMinutes(1));
await _service.ProjectAsync(run, CancellationToken.None);
var updated = run with
{
State = RunState.Completed,
StartedAt = run.StartedAt,
FinishedAt = run.CreatedAt.AddMinutes(5),
Stats = new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 10, deltas: 2, newCriticals: 1)
};
_timeProvider.Advance(TimeSpan.FromMinutes(10));
var projection = await _service.ProjectAsync(updated, CancellationToken.None);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Completed, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Completed);
Assert.Equal(0, projection.Counters.Planning);
Assert.Single(projection.Recent);
Assert.Equal(updated.Stats.Completed, projection.LastRun!.Stats.Completed);
Assert.True(projection.UpdatedAt > run.CreatedAt);
}
[Fact]
public async Task ProjectAsync_TrimsRecentEntriesBeyondLimit()
{
var baseTime = DateTimeOffset.Parse("2025-10-26T00:00:00Z");
for (var i = 0; i < 25; i++)
{
var run = TestDataFactory.CreateRun(
$"run-{i}",
"tenant-alpha",
RunState.Completed,
"sch-alpha",
stats: new RunStats(candidates: 5, deduped: 4, queued: 3, completed: 5, deltas: 1),
createdAt: baseTime.AddMinutes(i));
await _service.ProjectAsync(run, CancellationToken.None);
}
var projections = await _service.ListAsync("tenant-alpha", CancellationToken.None);
Assert.Single(projections);
var projection = projections[0];
Assert.Equal(20, projection.Recent.Length);
Assert.Equal(20, projection.Counters.Total);
Assert.Equal("run-24", projection.Recent[0].RunId);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
}
}
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Services;
public sealed class RunSummaryServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly RunSummaryRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly RunSummaryService _service;
public RunSummaryServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new RunSummaryRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T10:00:00Z"));
_service = new RunSummaryService(_repository, _timeProvider, NullLogger<RunSummaryService>.Instance);
}
[Fact]
public async Task ProjectAsync_FirstRunCreatesProjection()
{
var run = TestDataFactory.CreateRun("run-1", "tenant-alpha", RunState.Planning, "sch-alpha");
var projection = await _service.ProjectAsync(run, CancellationToken.None);
Assert.Equal("tenant-alpha", projection.TenantId);
Assert.Equal("sch-alpha", projection.ScheduleId);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Planning, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Total);
Assert.Equal(1, projection.Counters.Planning);
Assert.Equal(0, projection.Counters.Completed);
Assert.Single(projection.Recent);
Assert.Equal(run.Id, projection.Recent[0].RunId);
}
[Fact]
public async Task ProjectAsync_UpdateRunReplacesExistingEntry()
{
var createdAt = DateTimeOffset.Parse("2025-10-26T09:55:00Z");
var run = TestDataFactory.CreateRun(
"run-update",
"tenant-alpha",
RunState.Planning,
"sch-alpha",
createdAt: createdAt,
startedAt: createdAt.AddMinutes(1));
await _service.ProjectAsync(run, CancellationToken.None);
var updated = run with
{
State = RunState.Completed,
StartedAt = run.StartedAt,
FinishedAt = run.CreatedAt.AddMinutes(5),
Stats = new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 10, deltas: 2, newCriticals: 1)
};
_timeProvider.Advance(TimeSpan.FromMinutes(10));
var projection = await _service.ProjectAsync(updated, CancellationToken.None);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Completed, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Completed);
Assert.Equal(0, projection.Counters.Planning);
Assert.Single(projection.Recent);
Assert.Equal(updated.Stats.Completed, projection.LastRun!.Stats.Completed);
Assert.True(projection.UpdatedAt > run.CreatedAt);
}
[Fact]
public async Task ProjectAsync_TrimsRecentEntriesBeyondLimit()
{
var baseTime = DateTimeOffset.Parse("2025-10-26T00:00:00Z");
for (var i = 0; i < 25; i++)
{
var run = TestDataFactory.CreateRun(
$"run-{i}",
"tenant-alpha",
RunState.Completed,
"sch-alpha",
stats: new RunStats(candidates: 5, deduped: 4, queued: 3, completed: 5, deltas: 1),
createdAt: baseTime.AddMinutes(i));
await _service.ProjectAsync(run, CancellationToken.None);
}
var projections = await _service.ListAsync("tenant-alpha", CancellationToken.None);
Assert.Single(projections);
var projection = projections[0];
Assert.Equal(20, projection.Recent.Length);
Assert.Equal(20, projection.Counters.Total);
Assert.Equal("run-24", projection.Recent[0].RunId);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
}
}

View File

@@ -1,82 +1,82 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
public sealed class SchedulerAuditServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly AuditRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly SchedulerAuditService _service;
public SchedulerAuditServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new AuditRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T11:30:00Z"));
_service = new SchedulerAuditService(_repository, _timeProvider, NullLogger<SchedulerAuditService>.Instance);
}
[Fact]
public async Task WriteAsync_PersistsRecordWithGeneratedId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "create",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
CorrelationId: "corr-1",
Metadata: new Dictionary<string, string>
{
["Reason"] = "initial",
},
Message: "created schedule");
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.StartsWith("audit_", record.Id, StringComparison.Ordinal);
Assert.Equal(_timeProvider.GetUtcNow(), record.OccurredAt);
var stored = await _repository.ListAsync("tenant-alpha", new AuditQueryOptions { ScheduleId = "sch-alpha" }, session: null, CancellationToken.None);
Assert.Single(stored);
Assert.Equal(record.Id, stored[0].Id);
Assert.Equal("created schedule", stored[0].Message);
Assert.Contains(stored[0].Metadata, pair => pair.Key == "reason" && pair.Value == "initial");
}
[Fact]
public async Task WriteAsync_HonoursProvidedAuditId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "update",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
AuditId: "audit_custom_1",
OccurredAt: DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.Equal("audit_custom_1", record.Id);
Assert.Equal(DateTimeOffset.Parse("2025-10-26T12:00:00Z"), record.OccurredAt);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Services;
public sealed class SchedulerAuditServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly AuditRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly SchedulerAuditService _service;
public SchedulerAuditServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new AuditRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T11:30:00Z"));
_service = new SchedulerAuditService(_repository, _timeProvider, NullLogger<SchedulerAuditService>.Instance);
}
[Fact]
public async Task WriteAsync_PersistsRecordWithGeneratedId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "create",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
CorrelationId: "corr-1",
Metadata: new Dictionary<string, string>
{
["Reason"] = "initial",
},
Message: "created schedule");
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.StartsWith("audit_", record.Id, StringComparison.Ordinal);
Assert.Equal(_timeProvider.GetUtcNow(), record.OccurredAt);
var stored = await _repository.ListAsync("tenant-alpha", new AuditQueryOptions { ScheduleId = "sch-alpha" }, session: null, CancellationToken.None);
Assert.Single(stored);
Assert.Equal(record.Id, stored[0].Id);
Assert.Equal("created schedule", stored[0].Message);
Assert.Contains(stored[0].Metadata, pair => pair.Key == "reason" && pair.Value == "initial");
}
[Fact]
public async Task WriteAsync_HonoursProvidedAuditId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "update",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
AuditId: "audit_custom_1",
OccurredAt: DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.Equal("audit_custom_1", record.Id);
Assert.Equal(DateTimeOffset.Parse("2025-10-26T12:00:00Z"), record.OccurredAt);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -1,35 +1,35 @@
using System.Threading;
using MongoDB.Driver;
using StellaOps.Scheduler.Storage.Mongo.Sessions;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Sessions;
public sealed class SchedulerMongoSessionFactoryTests
{
[Fact]
public async Task StartSessionAsync_UsesCausalConsistencyByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
using var session = await factory.StartSessionAsync(cancellationToken: CancellationToken.None);
Assert.True(session.Options.CausalConsistency.GetValueOrDefault());
}
[Fact]
public async Task StartSessionAsync_AllowsOverridingOptions()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
var options = new SchedulerMongoSessionOptions
{
CausalConsistency = false,
ReadPreference = ReadPreference.PrimaryPreferred
};
using var session = await factory.StartSessionAsync(options);
Assert.False(session.Options.CausalConsistency.GetValueOrDefault(true));
Assert.Equal(ReadPreference.PrimaryPreferred, session.Options.DefaultTransactionOptions?.ReadPreference);
}
}
using System.Threading;
using MongoDB.Driver;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Sessions;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Sessions;
public sealed class SchedulerMongoSessionFactoryTests
{
[Fact]
public async Task StartSessionAsync_UsesCausalConsistencyByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
using var session = await factory.StartSessionAsync(cancellationToken: CancellationToken.None);
Assert.True(session.Options.CausalConsistency.GetValueOrDefault());
}
[Fact]
public async Task StartSessionAsync_AllowsOverridingOptions()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
var options = new SchedulerMongoSessionOptions
{
CausalConsistency = false,
ReadPreference = ReadPreference.PrimaryPreferred
};
using var session = await factory.StartSessionAsync(options);
Assert.False(session.Options.CausalConsistency.GetValueOrDefault(true));
Assert.Equal(ReadPreference.PrimaryPreferred, session.Options.DefaultTransactionOptions?.ReadPreference);
}
}

View File

@@ -7,7 +7,7 @@
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,98 +1,98 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
internal static class TestDataFactory
{
public static Schedule CreateSchedule(
string id,
string tenantId,
bool enabled = true,
string name = "Nightly Prod")
{
var now = DateTimeOffset.UtcNow;
return new Schedule(
id,
tenantId,
name,
enabled,
"0 2 * * *",
"UTC",
ScheduleMode.AnalysisOnly,
new Selector(SelectorScope.AllImages, tenantId),
ScheduleOnlyIf.Default,
ScheduleNotify.Default,
ScheduleLimits.Default,
now,
"svc_scheduler",
now,
"svc_scheduler",
ImmutableArray<string>.Empty,
SchedulerSchemaVersions.Schedule);
}
public static Run CreateRun(
string id,
string tenantId,
RunState state,
string? scheduleId = null,
RunTrigger trigger = RunTrigger.Manual,
RunStats? stats = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? startedAt = null)
{
var resolvedStats = stats ?? new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 0, deltas: 2);
var created = createdAt ?? DateTimeOffset.UtcNow;
return new Run(
id,
tenantId,
trigger,
state,
resolvedStats,
created,
scheduleId: scheduleId,
reason: new RunReason(manualReason: "test"),
startedAt: startedAt ?? created);
}
public static ImpactSet CreateImpactSet(string tenantId, string snapshotId, DateTimeOffset? generatedAt = null, bool usageOnly = true)
{
var selector = new Selector(SelectorScope.AllImages, tenantId);
var image = new ImpactImage(
"sha256:" + Guid.NewGuid().ToString("N"),
"registry",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "prod" },
usedByEntrypoint: true);
return new ImpactSet(
selector,
new[] { image },
usageOnly: usageOnly,
generatedAt ?? DateTimeOffset.UtcNow,
total: 1,
snapshotId: snapshotId,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
public static AuditRecord CreateAuditRecord(
string tenantId,
string idSuffix,
DateTimeOffset? occurredAt = null,
string? scheduleId = null,
string? category = null,
string? action = null)
{
return new AuditRecord(
$"audit_{idSuffix}",
tenantId,
category ?? "scheduler",
action ?? "create",
occurredAt ?? DateTimeOffset.UtcNow,
new AuditActor("user_admin", "Admin", "user"),
scheduleId: scheduleId ?? $"sch_{idSuffix}",
message: "created");
}
}
using System;
using System.Collections.Immutable;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests;
internal static class TestDataFactory
{
public static Schedule CreateSchedule(
string id,
string tenantId,
bool enabled = true,
string name = "Nightly Prod")
{
var now = DateTimeOffset.UtcNow;
return new Schedule(
id,
tenantId,
name,
enabled,
"0 2 * * *",
"UTC",
ScheduleMode.AnalysisOnly,
new Selector(SelectorScope.AllImages, tenantId),
ScheduleOnlyIf.Default,
ScheduleNotify.Default,
ScheduleLimits.Default,
now,
"svc_scheduler",
now,
"svc_scheduler",
ImmutableArray<string>.Empty,
SchedulerSchemaVersions.Schedule);
}
public static Run CreateRun(
string id,
string tenantId,
RunState state,
string? scheduleId = null,
RunTrigger trigger = RunTrigger.Manual,
RunStats? stats = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? startedAt = null)
{
var resolvedStats = stats ?? new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 0, deltas: 2);
var created = createdAt ?? DateTimeOffset.UtcNow;
return new Run(
id,
tenantId,
trigger,
state,
resolvedStats,
created,
scheduleId: scheduleId,
reason: new RunReason(manualReason: "test"),
startedAt: startedAt ?? created);
}
public static ImpactSet CreateImpactSet(string tenantId, string snapshotId, DateTimeOffset? generatedAt = null, bool usageOnly = true)
{
var selector = new Selector(SelectorScope.AllImages, tenantId);
var image = new ImpactImage(
"sha256:" + Guid.NewGuid().ToString("N"),
"registry",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "prod" },
usedByEntrypoint: true);
return new ImpactSet(
selector,
new[] { image },
usageOnly: usageOnly,
generatedAt ?? DateTimeOffset.UtcNow,
total: 1,
snapshotId: snapshotId,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
public static AuditRecord CreateAuditRecord(
string tenantId,
string idSuffix,
DateTimeOffset? occurredAt = null,
string? scheduleId = null,
string? category = null,
string? action = null)
{
return new AuditRecord(
$"audit_{idSuffix}",
tenantId,
category ?? "scheduler",
action ?? "create",
occurredAt ?? DateTimeOffset.UtcNow,
new AuditActor("user_admin", "Admin", "user"),
scheduleId: scheduleId ?? $"sch_{idSuffix}",
message: "created");
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Scheduler.Storage.Postgres.Tests;
[Collection(SchedulerPostgresCollection.Name)]
public sealed class GraphJobRepositoryTests : IAsyncLifetime
{
private readonly SchedulerPostgresFixture _fixture;
public GraphJobRepositoryTests(SchedulerPostgresFixture fixture)
{
_fixture = fixture;
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
private static GraphBuildJob BuildJob(string tenant, string id, GraphJobStatus status = GraphJobStatus.Pending)
=> new(
id: id,
tenantId: tenant,
sbomId: "sbom-1",
sbomVersionId: "sbom-ver-1",
sbomDigest: "sha256:abc",
status: status,
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()
{
var dataSource = CreateDataSource();
var repo = new GraphJobRepository(dataSource);
var job = BuildJob("t1", Guid.NewGuid().ToString());
await repo.InsertAsync(job, CancellationToken.None);
var fetched = await repo.GetBuildJobAsync("t1", job.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(job.Id);
fetched.Status.Should().Be(GraphJobStatus.Pending);
}
[Fact]
public async Task TryReplaceSucceedsWithExpectedStatus()
{
var dataSource = CreateDataSource();
var repo = new GraphJobRepository(dataSource);
var job = BuildJob("t1", Guid.NewGuid().ToString());
await repo.InsertAsync(job, CancellationToken.None);
var running = job with { Status = GraphJobStatus.Running };
var updated = await repo.TryReplaceAsync(running, GraphJobStatus.Pending, CancellationToken.None);
updated.Should().BeTrue();
var fetched = await repo.GetBuildJobAsync("t1", job.Id, CancellationToken.None);
fetched!.Status.Should().Be(GraphJobStatus.Running);
}
[Fact]
public async Task TryReplaceFailsOnUnexpectedStatus()
{
var dataSource = CreateDataSource();
var repo = new GraphJobRepository(dataSource);
var job = BuildJob("t1", Guid.NewGuid().ToString(), GraphJobStatus.Completed);
await repo.InsertAsync(job, CancellationToken.None);
var running = job with { Status = GraphJobStatus.Running };
var updated = await repo.TryReplaceAsync(running, GraphJobStatus.Pending, CancellationToken.None);
updated.Should().BeFalse();
}
[Fact]
public async Task ListBuildJobsHonorsStatusAndLimit()
{
var dataSource = CreateDataSource();
var repo = new GraphJobRepository(dataSource);
for (int i = 0; i < 5; i++)
{
await repo.InsertAsync(BuildJob("t1", Guid.NewGuid().ToString(), GraphJobStatus.Pending), CancellationToken.None);
}
var running = BuildJob("t1", Guid.NewGuid().ToString(), GraphJobStatus.Running);
await repo.InsertAsync(running, CancellationToken.None);
var pending = await repo.ListBuildJobsAsync("t1", GraphJobStatus.Pending, 3, CancellationToken.None);
pending.Count.Should().Be(3);
var runningList = await repo.ListBuildJobsAsync("t1", GraphJobStatus.Running, 10, CancellationToken.None);
runningList.Should().ContainSingle(j => j.Id == running.Id);
}
private SchedulerDataSource CreateDataSource()
{
var options = _fixture.Fixture.CreateOptions();
options.SchemaName = _fixture.SchemaName;
return new SchedulerDataSource(Options.Create(options));
}
}

View File

@@ -8,7 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.WebService.PolicySimulations;
using Xunit;

View File

@@ -10,7 +10,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
namespace StellaOps.Scheduler.WebService.Tests;
@@ -21,85 +21,85 @@ public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Progr
public RunEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateListCancelRun()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-runs");
}
[Fact]
public async Task CreateListCancelRun()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-runs");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "RunSchedule",
cronExpression = "0 3 * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
}
});
scheduleResponse.EnsureSuccessStatusCode();
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var createRun = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
{
scheduleId,
trigger = "manual"
});
createRun.EnsureSuccessStatusCode();
Assert.Equal(System.Net.HttpStatusCode.Created, createRun.StatusCode);
var runJson = await createRun.Content.ReadFromJsonAsync<JsonElement>();
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(runId));
Assert.Equal("planning", runJson.GetProperty("run").GetProperty("state").GetString());
var listResponse = await client.GetAsync("/api/v1/scheduler/runs");
listResponse.EnsureSuccessStatusCode();
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(listJson.GetProperty("runs").EnumerateArray().Any());
var cancelResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/cancel", null);
cancelResponse.EnsureSuccessStatusCode();
var cancelled = await cancelResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", cancelled.GetProperty("run").GetProperty("state").GetString());
var getResponse = await client.GetAsync($"/api/v1/scheduler/runs/{runId}");
getResponse.EnsureSuccessStatusCode();
var runDetail = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", runDetail.GetProperty("run").GetProperty("state").GetString());
}
[Fact]
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "RunSchedule",
cronExpression = "0 3 * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
}
});
scheduleResponse.EnsureSuccessStatusCode();
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var createRun = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
{
scheduleId,
trigger = "manual"
});
createRun.EnsureSuccessStatusCode();
Assert.Equal(System.Net.HttpStatusCode.Created, createRun.StatusCode);
var runJson = await createRun.Content.ReadFromJsonAsync<JsonElement>();
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(runId));
Assert.Equal("planning", runJson.GetProperty("run").GetProperty("state").GetString());
var listResponse = await client.GetAsync("/api/v1/scheduler/runs");
listResponse.EnsureSuccessStatusCode();
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(listJson.GetProperty("runs").EnumerateArray().Any());
var cancelResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/cancel", null);
cancelResponse.EnsureSuccessStatusCode();
var cancelled = await cancelResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", cancelled.GetProperty("run").GetProperty("state").GetString());
var getResponse = await client.GetAsync($"/api/v1/scheduler/runs/{runId}");
getResponse.EnsureSuccessStatusCode();
var runDetail = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", runDetail.GetProperty("run").GetProperty("state").GetString());
}
[Fact]
public async Task PreviewImpactForSchedule()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-preview");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "PreviewSchedule",
cronExpression = "0 5 * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
}
});
scheduleResponse.EnsureSuccessStatusCode();
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "PreviewSchedule",
cronExpression = "0 5 * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
}
});
scheduleResponse.EnsureSuccessStatusCode();
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var previewResponse = await client.PostAsJsonAsync("/api/v1/scheduler/runs/preview", new
{
scheduleId,

View File

@@ -1,244 +1,244 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Graph;
using StellaOps.Scheduler.Worker.Graph.Cartographer;
using StellaOps.Scheduler.Worker.Graph.Scheduler;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphBuildExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Equal(0, repository.ReplaceCalls);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
Result = new CartographerBuildResult(
GraphJobStatus.Completed,
CartographerJobId: "carto-1",
GraphSnapshotId: "graph_snap",
ResultUri: "oras://graph/result",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(10)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal(job.Id, notification.JobId);
Assert.Equal("Build", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/result", notification.ResultUri);
Assert.Equal("graph_snap", notification.GraphSnapshotId);
Assert.Null(notification.Error);
Assert.Equal(1, cartographer.CallCount);
Assert.True(repository.ReplaceCalls >= 1);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
ExceptionToThrow = new InvalidOperationException("network")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
Assert.Equal(2, cartographer.CallCount);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("network", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
private static GraphBuildJob CreateGraphJob() => new(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom-1",
sbomVersionId: "sbom-1-v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public int ReplaceCalls { get; private set; }
public bool ShouldReplaceSucceed { get; set; } = true;
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
ReplaceCalls++;
return Task.FromResult(true);
}
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerBuildClient : ICartographerBuildClient
{
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphBuildExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Equal(0, repository.ReplaceCalls);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
Result = new CartographerBuildResult(
GraphJobStatus.Completed,
CartographerJobId: "carto-1",
GraphSnapshotId: "graph_snap",
ResultUri: "oras://graph/result",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(10)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal(job.Id, notification.JobId);
Assert.Equal("Build", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/result", notification.ResultUri);
Assert.Equal("graph_snap", notification.GraphSnapshotId);
Assert.Null(notification.Error);
Assert.Equal(1, cartographer.CallCount);
Assert.True(repository.ReplaceCalls >= 1);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
ExceptionToThrow = new InvalidOperationException("network")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
Assert.Equal(2, cartographer.CallCount);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("network", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
private static GraphBuildJob CreateGraphJob() => new(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom-1",
sbomVersionId: "sbom-1-v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public int ReplaceCalls { get; private set; }
public bool ShouldReplaceSucceed { get; set; } = true;
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
ReplaceCalls++;
return Task.FromResult(true);
}
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerBuildClient : ICartographerBuildClient
{
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -1,238 +1,238 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Graph;
using StellaOps.Scheduler.Worker.Graph.Cartographer;
using StellaOps.Scheduler.Worker.Graph.Scheduler;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphOverlayExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
Result = new CartographerOverlayResult(
GraphJobStatus.Completed,
GraphSnapshotId: "graph_snap_2",
ResultUri: "oras://graph/overlay",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(5)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal("Overlay", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/overlay", notification.ResultUri);
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterRetries()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
ExceptionToThrow = new InvalidOperationException("overlay failed")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("overlay failed", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
private static GraphOverlayJob CreateOverlayJob() => new(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "snap-1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public bool ShouldReplaceSucceed { get; set; } = true;
public int RunningReplacements { get; private set; }
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
RunningReplacements++;
return Task.FromResult(true);
}
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
{
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphOverlayExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
Result = new CartographerOverlayResult(
GraphJobStatus.Completed,
GraphSnapshotId: "graph_snap_2",
ResultUri: "oras://graph/overlay",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(5)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal("Overlay", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/overlay", notification.ResultUri);
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterRetries()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
ExceptionToThrow = new InvalidOperationException("overlay failed")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("overlay failed", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
private static GraphOverlayJob CreateOverlayJob() => new(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "snap-1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public bool ShouldReplaceSucceed { get; set; } = true;
public int RunningReplacements { get; private set; }
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
RunningReplacements++;
return Task.FromResult(true);
}
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
{
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -6,9 +6,9 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Planning;

View File

@@ -5,9 +5,9 @@ using MongoDB.Driver;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Planning;
using StellaOps.Scheduler.Worker.Observability;

View File

@@ -6,7 +6,7 @@ using MongoDB.Driver;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Policy;
using StellaOps.Scheduler.Worker.Observability;

View File

@@ -1,80 +1,80 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunExecutionServiceTests
{
private static readonly SchedulerWorkerOptions WorkerOptions = new()
{
Policy =
{
Dispatch =
{
LeaseOwner = "test-dispatch",
BatchSize = 1,
LeaseDuration = TimeSpan.FromMinutes(1),
IdleDelay = TimeSpan.FromMilliseconds(10),
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromSeconds(30)
},
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/api/policy/policies/{policyId}/runs",
SimulatePath = "/api/policy/policies/{policyId}/simulate"
}
}
};
[Fact]
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunExecutionServiceTests
{
private static readonly SchedulerWorkerOptions WorkerOptions = new()
{
Policy =
{
Dispatch =
{
LeaseOwner = "test-dispatch",
BatchSize = 1,
LeaseDuration = TimeSpan.FromMinutes(1),
IdleDelay = TimeSpan.FromMilliseconds(10),
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromSeconds(30)
},
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/api/policy/policies/{policyId}/runs",
SimulatePath = "/api/policy/policies/{policyId}/simulate"
}
}
};
[Fact]
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
CancellationRequested = true,
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
CancellationRequested = true,
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
Assert.True(repository.ReplaceCalled);
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
Assert.Single(webhook.Payloads);
Assert.Equal("cancelled", webhook.Payloads[0].Result);
}
[Fact]
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
{
var repository = new RecordingPolicyRunJobRepository();
}
[Fact]
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Succeeded("run:P-7:2025", DateTimeOffset.Parse("2025-10-28T10:01:00Z"))
@@ -88,33 +88,33 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
Assert.Equal(PolicyRunJobStatus.Submitted, result.UpdatedJob.Status);
Assert.Equal("run:P-7:2025", result.UpdatedJob.RunId);
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
Assert.Null(result.UpdatedJob.LastError);
Assert.True(repository.ReplaceCalled);
Assert.Empty(webhook.Payloads);
}
[Fact]
public async Task ExecuteAsync_RetriesJob_OnFailure()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("timeout")
};
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
}
[Fact]
public async Task ExecuteAsync_RetriesJob_OnFailure()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("timeout")
};
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
@@ -123,35 +123,35 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
Assert.Equal("timeout", result.UpdatedJob.LastError);
Assert.True(result.UpdatedJob.AvailableAt > job.AvailableAt);
Assert.Empty(webhook.Payloads);
}
[Fact]
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("bad request")
};
var optionsValue = CloneOptions();
optionsValue.Policy.Dispatch.MaxAttempts = 1;
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
}
[Fact]
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("bad request")
};
var optionsValue = CloneOptions();
optionsValue.Policy.Dispatch.MaxAttempts = 1;
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
@@ -159,13 +159,13 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Failed, result.Type);
@@ -173,100 +173,100 @@ public sealed class PolicyRunExecutionServiceTests
Assert.Equal("bad request", result.UpdatedJob.LastError);
Assert.Single(webhook.Payloads);
Assert.Equal("failed", webhook.Payloads[0].Result);
}
[Fact]
public async Task ExecuteAsync_NoWork_CompletesJob()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
}
[Fact]
public async Task ExecuteAsync_NoWork_CompletesJob()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
OnEnsureTargets = job => PolicyRunTargetingResult.NoWork(job, "empty")
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.NoOp, result.Type);
Assert.Equal(PolicyRunJobStatus.Completed, result.UpdatedJob.Status);
Assert.True(repository.ReplaceCalled);
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
Assert.Single(webhook.Payloads);
Assert.Equal("succeeded", webhook.Payloads[0].Result);
}
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
{
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.Normal,
PriorityRank: -1,
RunId: "run:P-7:2025",
RequestedBy: "user:cli",
CorrelationId: "corr-1",
Metadata: metadata,
Inputs: resolvedInputs,
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
Status: status,
AttemptCount: attemptCount,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private static SchedulerWorkerOptions CloneOptions()
{
return new SchedulerWorkerOptions
{
Policy = new SchedulerWorkerOptions.PolicyOptions
{
Enabled = WorkerOptions.Policy.Enabled,
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
{
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
},
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
{
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
RunsPath = WorkerOptions.Policy.Api.RunsPath,
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
},
}
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
{
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.Normal,
PriorityRank: -1,
RunId: "run:P-7:2025",
RequestedBy: "user:cli",
CorrelationId: "corr-1",
Metadata: metadata,
Inputs: resolvedInputs,
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
Status: status,
AttemptCount: attemptCount,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private static SchedulerWorkerOptions CloneOptions()
{
return new SchedulerWorkerOptions
{
Policy = new SchedulerWorkerOptions.PolicyOptions
{
Enabled = WorkerOptions.Policy.Enabled,
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
{
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
},
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
{
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
RunsPath = WorkerOptions.Policy.Api.RunsPath,
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
},
Targeting = new SchedulerWorkerOptions.PolicyOptions.TargetingOptions
{
Enabled = WorkerOptions.Policy.Targeting.Enabled,
@@ -284,15 +284,15 @@ public sealed class PolicyRunExecutionServiceTests
}
};
}
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
{
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
}
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
{
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
}
private sealed class RecordingPolicySimulationWebhookClient : IPolicySimulationWebhookClient
{
public List<PolicySimulationWebhookPayload> Payloads { get; } = new();
@@ -306,13 +306,13 @@ public sealed class PolicyRunExecutionServiceTests
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
{
public bool ReplaceCalled { get; private set; }
public string? ExpectedLeaseOwner { get; private set; }
public PolicyRunJob? LastJob { get; private set; }
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public bool ReplaceCalled { get; private set; }
public string? ExpectedLeaseOwner { get; private set; }
public PolicyRunJob? LastJob { get; private set; }
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public Task<PolicyRunJob?> GetByRunIdAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
@@ -327,38 +327,38 @@ public sealed class PolicyRunExecutionServiceTests
public Task<PolicyRunJob?> LeaseAsync(string leaseOwner, DateTimeOffset now, TimeSpan leaseDuration, int maxAttempts, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
ReplaceCalled = true;
ExpectedLeaseOwner = expectedLeaseOwner;
LastJob = job;
return Task.FromResult(true);
}
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
}
private sealed class StubPolicyRunClient : IPolicyRunClient
{
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
ReplaceCalled = true;
ExpectedLeaseOwner = expectedLeaseOwner;
LastJob = job;
return Task.FromResult(true);
}
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
}
private sealed class StubPolicyRunClient : IPolicyRunClient
{
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -9,9 +9,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Worker.Events;
using StellaOps.Scheduler.Worker.Execution;
using StellaOps.Scheduler.Worker.Observability;