Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user