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
				
			
		
		
	
	
				
					
				
			
		
			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:
		
							
								
								
									
										239
									
								
								src/StellaOps.Feedser.Core.Tests/CanonicalMergerTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								src/StellaOps.Feedser.Core.Tests/CanonicalMergerTests.cs
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										483
									
								
								src/StellaOps.Feedser.Core.Tests/JobCoordinatorTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										483
									
								
								src/StellaOps.Feedser.Core.Tests/JobCoordinatorTests.cs
									
									
									
									
									
										Normal 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); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/StellaOps.Feedser.Core.Tests/JobSchedulerBuilderTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/StellaOps.Feedser.Core.Tests/JobSchedulerBuilderTests.cs
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										42
									
								
								src/StellaOps.Feedser.Core.Tests/PluginRoutineFixtures.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/StellaOps.Feedser.Core.Tests/PluginRoutineFixtures.cs
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
| @@ -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> | ||||
		Reference in New Issue
	
	Block a user