Initial commit (history squashed)
Some checks failed
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-07 10:14:21 +03:00
commit b97fc7685a
1132 changed files with 117842 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
using StellaOps.Feedser.Models;
namespace StellaOps.Feedser.Core.Tests;
public sealed class CanonicalMergerTests
{
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-aaaa-bbbb-cccc",
title: "GHSA Title",
summary: "GHSA Summary",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-0001",
title: "NVD Title",
summary: "NVD Summary",
modified: BaseTimestamp);
var result = merger.Merge("CVE-2025-0001", ghsa, nvd, null);
Assert.Equal("GHSA Title", result.Advisory.Title);
Assert.Equal("GHSA Summary", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "Initial GHSA summary.",
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "OSV summary with additional mitigation steps.",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-9000", ghsa, null, osv);
Assert.Equal("OSV summary with additional mitigation steps.", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.3",
lastAffectedVersion: null,
rangeExpression: "<1.2.3",
provenance: CreateProvenance("ghsa", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: new[]
{
new AffectedPackageStatus(
"affected",
CreateProvenance("ghsa", ProvenanceFieldMasks.PackageStatuses))
},
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.4",
lastAffectedVersion: null,
rangeExpression: "<1.2.4",
provenance: CreateProvenance("nvd", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("nvd", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.5",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.5",
provenance: CreateProvenance("osv", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory("ghsa", "GHSA-1234", "GHSA Title", null, BaseTimestamp.AddHours(1), packages: new[] { ghsaPackage });
var nvd = CreateAdvisory("nvd", "CVE-2025-1111", "NVD Title", null, BaseTimestamp.AddHours(2), packages: new[] { nvdPackage });
var osv = CreateAdvisory("osv", "OSV-2025-xyz", "OSV Title", null, BaseTimestamp.AddHours(3), packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-1111", ghsa, nvd, osv);
var package = Assert.Single(result.Advisory.AffectedPackages);
Assert.Equal("pkg:npm/example@1", package.Identifier);
Assert.Contains(package.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Decisions, decision =>
decision.Field.StartsWith("affectedPackages", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
var nvdMetric = new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 9.8, "critical", CreateProvenance("nvd", ProvenanceFieldMasks.CvssMetrics));
var ghsaMetric = new CvssMetric("3.0", "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H", 7.5, "high", CreateProvenance("ghsa", ProvenanceFieldMasks.CvssMetrics));
var nvd = CreateAdvisory("nvd", "CVE-2025-2000", "NVD Title", severity: null, modified: BaseTimestamp, metrics: new[] { nvdMetric });
var ghsa = CreateAdvisory("ghsa", "GHSA-9999", "GHSA Title", severity: null, modified: BaseTimestamp.AddHours(1), metrics: new[] { ghsaMetric });
var result = merger.Merge("CVE-2025-2000", ghsa, nvd, null);
Assert.Equal(2, result.Advisory.CvssMetrics.Length);
Assert.Equal("nvd", result.Decisions.Single(decision => decision.Field == "cvssMetrics").SelectedSource);
Assert.Equal("critical", result.Advisory.Severity);
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H");
}
private static Advisory CreateAdvisory(
string source,
string advisoryKey,
string title,
string? summary = null,
DateTimeOffset? modified = null,
string? severity = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: advisoryKey,
recordedAt: modified ?? BaseTimestamp,
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: modified,
modified: modified,
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private static AdvisoryProvenance CreateProvenance(string source, string fieldMask)
=> new(
source,
kind: "map",
value: source,
recordedAt: BaseTimestamp,
fieldMask: new[] { fieldMask });
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Feedser.Core.Jobs;
namespace StellaOps.Feedser.Core.Tests;
public sealed class JobCoordinatorTests
{
[Fact]
public async Task TriggerAsync_RunCompletesSuccessfully()
{
var services = new ServiceCollection();
services.AddTransient<SuccessfulJob>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new InMemoryLeaseStore();
var jobOptions = new JobSchedulerOptions
{
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
DefaultTimeout = TimeSpan.FromSeconds(10),
};
var definition = new JobDefinition(
Kind: "test:run",
JobType: typeof(SuccessfulJob),
Timeout: TimeSpan.FromSeconds(5),
LeaseDuration: TimeSpan.FromSeconds(2),
CronExpression: null,
Enabled: true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var result = await coordinator.TriggerAsync(definition.Kind, new Dictionary<string, object?> { ["foo"] = "bar" }, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(JobRunStatus.Succeeded, completed.Status);
await leaseStore.WaitForReleaseAsync(TimeSpan.FromSeconds(1));
Assert.True(leaseStore.ReleaseCount > 0);
Assert.Equal("bar", completed.Parameters["foo"]);
}
[Fact]
public async Task TriggerAsync_MarksRunFailed_WhenLeaseReleaseFails()
{
var services = new ServiceCollection();
services.AddTransient<SuccessfulJob>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new FailingLeaseStore
{
ThrowOnRelease = true,
};
var jobOptions = new JobSchedulerOptions
{
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
DefaultTimeout = TimeSpan.FromSeconds(10),
};
var definition = new JobDefinition(
Kind: "test:run",
JobType: typeof(SuccessfulJob),
Timeout: TimeSpan.FromSeconds(5),
LeaseDuration: TimeSpan.FromSeconds(2),
CronExpression: null,
Enabled: true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(JobRunStatus.Failed, completed.Status);
Assert.NotNull(completed.Error);
Assert.Contains("Failed to release lease", completed.Error!, StringComparison.OrdinalIgnoreCase);
Assert.True(leaseStore.ReleaseAttempts > 0);
}
[Fact]
public async Task TriggerAsync_MarksRunFailed_WhenLeaseHeartbeatFails()
{
var services = new ServiceCollection();
services.AddTransient<SlowJob>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new FailingLeaseStore
{
ThrowOnHeartbeat = true,
};
var jobOptions = new JobSchedulerOptions
{
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
DefaultTimeout = TimeSpan.FromSeconds(10),
};
var definition = new JobDefinition(
Kind: "test:heartbeat",
JobType: typeof(SlowJob),
Timeout: TimeSpan.FromSeconds(5),
LeaseDuration: TimeSpan.FromSeconds(2),
CronExpression: null,
Enabled: true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(6));
Assert.Equal(JobRunStatus.Failed, completed.Status);
Assert.NotNull(completed.Error);
Assert.Contains("Failed to heartbeat lease", completed.Error!, StringComparison.OrdinalIgnoreCase);
Assert.True(leaseStore.HeartbeatCount > 0);
}
[Fact]
public async Task TriggerAsync_ReturnsAlreadyRunning_WhenLeaseUnavailable()
{
var services = new ServiceCollection();
services.AddTransient<SuccessfulJob>();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new InMemoryLeaseStore
{
NextLease = null,
};
var jobOptions = new JobSchedulerOptions();
var definition = new JobDefinition(
"test:run",
typeof(SuccessfulJob),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(2),
null,
true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.AlreadyRunning, result.Outcome);
Assert.False(jobStore.CreatedRuns.Any());
}
[Fact]
public async Task TriggerAsync_ReturnsInvalidParameters_ForUnsupportedPayload()
{
var services = new ServiceCollection();
services.AddTransient<SuccessfulJob>();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new InMemoryLeaseStore();
var jobOptions = new JobSchedulerOptions();
var definition = new JobDefinition(
"test:run",
typeof(SuccessfulJob),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(2),
null,
true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var parameters = new Dictionary<string, object?>
{
["bad"] = new object(),
};
var result = await coordinator.TriggerAsync(definition.Kind, parameters, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.InvalidParameters, result.Outcome);
Assert.Contains("unsupported", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(jobStore.CreatedRuns.Any());
}
[Fact]
public async Task TriggerAsync_CancelsJobOnTimeout()
{
var services = new ServiceCollection();
services.AddTransient<TimeoutJob>();
using var provider = services.BuildServiceProvider();
var jobStore = new InMemoryJobStore();
var leaseStore = new InMemoryLeaseStore();
var jobOptions = new JobSchedulerOptions
{
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
DefaultTimeout = TimeSpan.FromMilliseconds(100),
};
var definition = new JobDefinition(
Kind: "test:timeout",
JobType: typeof(TimeoutJob),
Timeout: TimeSpan.FromMilliseconds(100),
LeaseDuration: TimeSpan.FromSeconds(2),
CronExpression: null,
Enabled: true);
jobOptions.Definitions.Add(definition.Kind, definition);
using var diagnostics = new JobDiagnostics();
var coordinator = new JobCoordinator(
Options.Create(jobOptions),
jobStore,
leaseStore,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<JobCoordinator>.Instance,
NullLoggerFactory.Instance,
new TestTimeProvider(),
diagnostics);
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(JobRunStatus.Cancelled, completed.Status);
await leaseStore.WaitForReleaseAsync(TimeSpan.FromSeconds(1));
Assert.True(leaseStore.ReleaseCount > 0);
}
private sealed class SuccessfulJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class TimeoutJob : IJob
{
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
private sealed class SlowJob : IJob
{
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}
}
private sealed class InMemoryJobStore : IJobStore
{
private readonly Dictionary<Guid, JobRunSnapshot> _runs = new();
public TaskCompletionSource<JobRunSnapshot> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<JobRunSnapshot> CreatedRuns { get; } = new();
public Task<JobRunSnapshot> CreateAsync(JobRunCreateRequest request, CancellationToken cancellationToken)
{
var run = new JobRunSnapshot(
Guid.NewGuid(),
request.Kind,
JobRunStatus.Pending,
request.CreatedAt,
null,
null,
request.Trigger,
request.ParametersHash,
null,
request.Timeout,
request.LeaseDuration,
request.Parameters);
_runs[run.RunId] = run;
CreatedRuns.Add(run);
return Task.FromResult(run);
}
public Task<JobRunSnapshot?> TryStartAsync(Guid runId, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
if (_runs.TryGetValue(runId, out var run))
{
var updated = run with { Status = JobRunStatus.Running, StartedAt = startedAt };
_runs[runId] = updated;
return Task.FromResult<JobRunSnapshot?>(updated);
}
return Task.FromResult<JobRunSnapshot?>(null);
}
public Task<JobRunSnapshot?> TryCompleteAsync(Guid runId, JobRunCompletion completion, CancellationToken cancellationToken)
{
if (_runs.TryGetValue(runId, out var run))
{
var updated = run with { Status = completion.Status, CompletedAt = completion.CompletedAt, Error = completion.Error };
_runs[runId] = updated;
Completion.TrySetResult(updated);
return Task.FromResult<JobRunSnapshot?>(updated);
}
return Task.FromResult<JobRunSnapshot?>(null);
}
public Task<JobRunSnapshot?> FindAsync(Guid runId, CancellationToken cancellationToken)
{
_runs.TryGetValue(runId, out var run);
return Task.FromResult<JobRunSnapshot?>(run);
}
public Task<IReadOnlyList<JobRunSnapshot>> GetRecentRunsAsync(string? kind, int limit, CancellationToken cancellationToken)
{
var query = _runs.Values.AsEnumerable();
if (!string.IsNullOrWhiteSpace(kind))
{
query = query.Where(r => r.Kind == kind);
}
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(query.OrderByDescending(r => r.CreatedAt).Take(limit).ToArray());
}
public Task<IReadOnlyList<JobRunSnapshot>> GetActiveRunsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(_runs.Values.Where(r => r.Status is JobRunStatus.Pending or JobRunStatus.Running).ToArray());
}
public Task<JobRunSnapshot?> GetLastRunAsync(string kind, CancellationToken cancellationToken)
{
var run = _runs.Values
.Where(r => r.Kind == kind)
.OrderByDescending(r => r.CreatedAt)
.FirstOrDefault();
return Task.FromResult<JobRunSnapshot?>(run);
}
public Task<IReadOnlyDictionary<string, JobRunSnapshot>> GetLastRunsAsync(IEnumerable<string> kinds, CancellationToken cancellationToken)
{
var results = new Dictionary<string, JobRunSnapshot>(StringComparer.Ordinal);
foreach (var kind in kinds.Distinct(StringComparer.Ordinal))
{
var run = _runs.Values
.Where(r => r.Kind == kind)
.OrderByDescending(r => r.CreatedAt)
.FirstOrDefault();
if (run is not null)
{
results[kind] = run;
}
}
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(results);
}
}
private sealed class InMemoryLeaseStore : ILeaseStore
{
public JobLease? NextLease { get; set; } = new JobLease("job:test:run", "holder", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow.AddSeconds(2));
public int HeartbeatCount { get; private set; }
public int ReleaseCount { get; private set; }
private readonly TaskCompletionSource<bool> _released = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<JobLease?> TryAcquireAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
{
return Task.FromResult(NextLease);
}
public Task<JobLease?> HeartbeatAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
{
HeartbeatCount++;
NextLease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
return Task.FromResult<JobLease?>(NextLease);
}
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
{
ReleaseCount++;
_released.TrySetResult(true);
return Task.FromResult(true);
}
public Task WaitForReleaseAsync(TimeSpan timeout)
=> _released.Task.WaitAsync(timeout);
}
private sealed class FailingLeaseStore : ILeaseStore
{
public bool ThrowOnHeartbeat { get; set; }
public bool ThrowOnRelease { get; set; }
public int HeartbeatCount { get; private set; }
public int ReleaseAttempts { get; private set; }
public Task<JobLease?> TryAcquireAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
{
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
return Task.FromResult<JobLease?>(lease);
}
public Task<JobLease?> HeartbeatAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
{
HeartbeatCount++;
if (ThrowOnHeartbeat)
{
throw new InvalidOperationException("Lease heartbeat failed");
}
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
return Task.FromResult<JobLease?>(lease);
}
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
{
ReleaseAttempts++;
if (ThrowOnRelease)
{
throw new InvalidOperationException("Failed to release lease");
}
return Task.FromResult(true);
}
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.Parse("2024-01-01T00:00:00Z");
public override DateTimeOffset GetUtcNow() => _now = _now.AddMilliseconds(100);
}
}

View File

@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Feedser.Core.Jobs;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Feedser.Core.Tests;
public sealed class JobPluginRegistrationExtensionsTests
{
[Fact]
public void RegisterJobPluginRoutines_LoadsPluginsAndRegistersDefinitions()
{
var services = new ServiceCollection();
services.AddJobScheduler();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["plugin:test:timeoutSeconds"] = "45",
})
.Build();
var assemblyPath = typeof(JobPluginRegistrationExtensionsTests).Assembly.Location;
var pluginDirectory = Path.GetDirectoryName(assemblyPath)!;
var pluginFile = Path.GetFileName(assemblyPath);
var options = new PluginHostOptions
{
BaseDirectory = pluginDirectory,
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(pluginFile);
services.RegisterJobPluginRoutines(configuration, options);
Assert.Contains(
services,
descriptor => descriptor.ServiceType == typeof(PluginHostResult));
Assert.Contains(
services,
descriptor => descriptor.ServiceType.FullName == typeof(PluginRoutineExecuted).FullName);
using var provider = services.BuildServiceProvider();
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(PluginJob.JobKind, out var definition));
Assert.NotNull(definition);
Assert.Equal(PluginJob.JobKind, definition.Kind);
Assert.Equal("StellaOps.Feedser.Core.Tests.PluginJob", definition.JobType.FullName);
Assert.Equal(TimeSpan.FromSeconds(45), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(5), definition.LeaseDuration);
Assert.Equal("*/10 * * * *", definition.CronExpression);
}
}

View File

@@ -0,0 +1,70 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Feedser.Core.Jobs;
namespace StellaOps.Feedser.Core.Tests;
public sealed class JobSchedulerBuilderTests
{
[Fact]
public void AddJob_RegistersDefinitionWithExplicitMetadata()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler();
builder.AddJob<TestJob>(
kind: "jobs:test",
cronExpression: "*/5 * * * *",
timeout: TimeSpan.FromMinutes(42),
leaseDuration: TimeSpan.FromMinutes(7),
enabled: false);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:test", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(TestJob), definition.JobType);
Assert.Equal(TimeSpan.FromMinutes(42), definition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(7), definition.LeaseDuration);
Assert.Equal("*/5 * * * *", definition.CronExpression);
Assert.False(definition.Enabled);
}
[Fact]
public void AddJob_UsesDefaults_WhenOptionalMetadataExcluded()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler(options =>
{
options.DefaultTimeout = TimeSpan.FromSeconds(123);
options.DefaultLeaseDuration = TimeSpan.FromSeconds(45);
});
builder.AddJob<DefaultedJob>(kind: "jobs:defaults");
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:defaults", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(DefaultedJob), definition.JobType);
Assert.Equal(TimeSpan.FromSeconds(123), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(45), definition.LeaseDuration);
Assert.Null(definition.CronExpression);
Assert.True(definition.Enabled);
}
private sealed class TestJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class DefaultedJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Feedser.Core.Jobs;
namespace StellaOps.Feedser.Core.Tests;
public sealed class TestPluginRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var builder = new JobSchedulerBuilder(services);
var timeoutSeconds = configuration.GetValue<int?>("plugin:test:timeoutSeconds") ?? 30;
builder.AddJob<PluginJob>(
PluginJob.JobKind,
cronExpression: "*/10 * * * *",
timeout: TimeSpan.FromSeconds(timeoutSeconds),
leaseDuration: TimeSpan.FromSeconds(5));
services.AddSingleton<PluginRoutineExecuted>();
return services;
}
}
public sealed class PluginRoutineExecuted
{
}
public sealed class PluginJob : IJob
{
public const string JobKind = "plugin:test";
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
</ItemGroup>
</Project>