Refactor code structure for improved readability and maintainability
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-06 21:48:12 +02:00
parent f6c22854a4
commit dd0067ea0b
105 changed files with 12662 additions and 427 deletions

View File

@@ -0,0 +1,174 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.WebService.Deprecation;
namespace StellaOps.TaskRunner.Tests;
public sealed class ApiDeprecationTests
{
[Fact]
public void DeprecatedEndpoint_PathPattern_MatchesExpected()
{
var endpoint = new DeprecatedEndpoint
{
PathPattern = "/v1/legacy/*",
DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30),
SunsetAt = DateTimeOffset.UtcNow.AddDays(60),
ReplacementPath = "/v2/new",
Message = "Use the v2 API"
};
Assert.Equal("/v1/legacy/*", endpoint.PathPattern);
Assert.NotNull(endpoint.DeprecatedAt);
Assert.NotNull(endpoint.SunsetAt);
}
[Fact]
public void ApiDeprecationOptions_DefaultValues_AreCorrect()
{
var options = new ApiDeprecationOptions();
Assert.True(options.EmitDeprecationHeaders);
Assert.True(options.EmitSunsetHeaders);
Assert.NotNull(options.DeprecationPolicyUrl);
Assert.Empty(options.DeprecatedEndpoints);
}
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint
{
PathPattern = "/v1/soon/*",
SunsetAt = now.AddDays(30) // Within 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/later/*",
SunsetAt = now.AddDays(180) // Beyond 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/past/*",
SunsetAt = now.AddDays(-10) // Already passed
}
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
Assert.Single(upcoming);
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
}
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) },
new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) },
new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) }
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
Assert.Equal(3, upcoming.Count);
Assert.Equal("/v1/first/*", upcoming[0].EndpointPath);
Assert.Equal("/v1/second/*", upcoming[1].EndpointPath);
Assert.Equal("/v1/third/*", upcoming[2].EndpointPath);
}
[Fact]
public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly()
{
var now = DateTimeOffset.UtcNow;
var sunsetDate = now.AddDays(45);
var info = new DeprecationInfo(
"/v1/test/*",
now.AddDays(-30),
sunsetDate,
"/v2/test/*",
"https://docs.example.com/migration",
45);
Assert.Equal(45, info.DaysUntilSunset);
Assert.Equal("/v2/test/*", info.ReplacementPath);
}
[Fact]
public void DeprecationNotification_RecordProperties_AreAccessible()
{
var notification = new DeprecationNotification(
"/v1/legacy/endpoint",
"/v2/new/endpoint",
DateTimeOffset.UtcNow.AddDays(90),
"This endpoint is deprecated",
"https://docs.example.com/deprecation",
["consumer-1", "consumer-2"]);
Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath);
Assert.Equal("/v2/new/endpoint", notification.ReplacementPath);
Assert.NotNull(notification.SunsetDate);
Assert.Equal(2, notification.AffectedConsumerIds?.Count);
}
[Fact]
public void PathPattern_WildcardToRegex_MatchesSingleSegment()
{
var pattern = "^" + Regex.Escape("/v1/packs/*")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/packs/foo");
Assert.Matches(pattern, "/v1/packs/bar");
Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match /
Assert.DoesNotMatch(pattern, "/v2/packs/foo");
}
[Fact]
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
{
var pattern = "^" + Regex.Escape("/v1/legacy/**")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/legacy/foo");
Assert.Matches(pattern, "/v1/legacy/foo/bar");
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
}
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
{
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
public ApiDeprecationOptions CurrentValue { get; }
public ApiDeprecationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
}
}

View File

@@ -9,12 +9,15 @@ public sealed class OpenApiMetadataFactoryTests
{
var metadata = OpenApiMetadataFactory.Create();
Assert.Equal("/openapi", metadata.Url);
Assert.False(string.IsNullOrWhiteSpace(metadata.Build));
Assert.Equal("/openapi", metadata.SpecUrl);
Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version);
Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion));
Assert.StartsWith("W/\"", metadata.ETag);
Assert.EndsWith("\"", metadata.ETag);
Assert.Equal(64, metadata.Signature.Length);
Assert.True(metadata.Signature.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
Assert.StartsWith("sha256:", metadata.Signature);
var hashPart = metadata.Signature["sha256:".Length..];
Assert.Equal(64, hashPart.Length);
Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
}
[Fact]
@@ -22,6 +25,26 @@ public sealed class OpenApiMetadataFactoryTests
{
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
Assert.Equal("/docs/openapi.json", metadata.Url);
Assert.Equal("/docs/openapi.json", metadata.SpecUrl);
}
[Fact]
public void Create_SignatureIncludesAllComponents()
{
var metadata1 = OpenApiMetadataFactory.Create("/path1");
var metadata2 = OpenApiMetadataFactory.Create("/path2");
// Different URLs should produce different signatures
Assert.NotEqual(metadata1.Signature, metadata2.Signature);
}
[Fact]
public void Create_ETagIsDeterministic()
{
var metadata1 = OpenApiMetadataFactory.Create();
var metadata2 = OpenApiMetadataFactory.Create();
// Same inputs should produce same ETag
Assert.Equal(metadata1.ETag, metadata2.ETag);
}
}

View File

@@ -21,11 +21,14 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj" />
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" />
<!-- OpenApiMetadataFactory is now accessible via WebService project reference -->
<!-- <Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" /> -->
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,242 @@
using System.Text;
using StellaOps.TaskRunner.Client.Models;
using StellaOps.TaskRunner.Client.Streaming;
using StellaOps.TaskRunner.Client.Pagination;
using StellaOps.TaskRunner.Client.Lifecycle;
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskRunnerClientTests
{
[Fact]
public async Task StreamingLogReader_ParsesNdjsonLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("info", entries[0].Level);
Assert.Equal("error", entries[1].Level);
Assert.Equal("step-1", entries[0].StepId);
Assert.Equal("Starting", entries[0].Message);
}
[Fact]
public async Task StreamingLogReader_SkipsEmptyLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
}
[Fact]
public async Task StreamingLogReader_SkipsMalformedLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"}
not valid json
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("Valid", entries[0].Message);
Assert.Equal("AlsoValid", entries[1].Message);
}
[Fact]
public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly()
{
var ct = TestContext.Current.CancellationToken;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"),
new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"),
new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"),
};
var levels = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "error", "warning" };
var filtered = new List<RunLogEntry>();
await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct))
{
filtered.Add(entry);
}
Assert.Equal(2, filtered.Count);
Assert.DoesNotContain(filtered, e => e.Level == "info");
}
[Fact]
public async Task StreamingLogReader_GroupByStep_GroupsCorrectly()
{
var ct = TestContext.Current.CancellationToken;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"),
new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"),
};
var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct);
Assert.Equal(3, groups.Count);
Assert.Equal(2, groups["step-1"].Count);
Assert.Single(groups["step-2"]);
Assert.Single(groups["(global)"]);
}
[Fact]
public async Task Paginator_IteratesAllPages()
{
var ct = TestContext.Current.CancellationToken;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var fetchCalls = 0;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
fetchCalls++;
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var collected = await paginator.CollectAsync(ct);
Assert.Equal(25, collected.Count);
Assert.Equal(3, fetchCalls); // 10, 10, 5 items
Assert.Equal(allItems, collected);
}
[Fact]
public async Task Paginator_GetPage_ReturnsCorrectPage()
{
var ct = TestContext.Current.CancellationToken;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var page2 = await paginator.GetPageAsync(2, ct);
Assert.Equal(10, page2.Items.Count);
Assert.Equal(11, page2.Items[0]); // Items 11-20
}
[Fact]
public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber()
{
var ct = TestContext.Current.CancellationToken;
var items = Enumerable.Range(1, 100).ToAsyncEnumerable();
var taken = new List<int>();
await foreach (var item in items.TakeAsync(5, ct))
{
taken.Add(item);
}
Assert.Equal(5, taken.Count);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken);
}
[Fact]
public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber()
{
var ct = TestContext.Current.CancellationToken;
var items = Enumerable.Range(1, 10).ToAsyncEnumerable();
var skipped = new List<int>();
await foreach (var item in items.SkipAsync(5, ct))
{
skipped.Add(item);
}
Assert.Equal(5, skipped.Count);
Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped);
}
[Fact]
public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses()
{
Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses);
}
[Fact]
public void PackRunModels_CreatePackRunRequest_SerializesCorrectly()
{
var request = new CreatePackRunRequest(
"my-pack",
"1.0.0",
new Dictionary<string, object> { ["key"] = "value" },
"tenant-1",
"corr-123");
Assert.Equal("my-pack", request.PackId);
Assert.Equal("1.0.0", request.PackVersion);
Assert.NotNull(request.Inputs);
Assert.Equal("value", request.Inputs["key"]);
}
[Fact]
public void PackRunModels_SimulatedStep_HasCorrectProperties()
{
var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100);
var step = new SimulatedStep(
"step-1",
"loop",
"WillIterate",
loopInfo,
null,
null);
Assert.Equal("step-1", step.StepId);
Assert.Equal("loop", step.Kind);
Assert.NotNull(step.LoopInfo);
Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression);
}
}
internal static class AsyncEnumerableExtensions
{
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
}