release orchestrator v1 draft and build fixes
This commit is contained in:
76
src/Plugin/StellaOps.Plugin.Testing/FakeTimeProvider.cs
Normal file
76
src/Plugin/StellaOps.Plugin.Testing/FakeTimeProvider.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for deterministic testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fake time provider starting at the specified time.
|
||||
/// </summary>
|
||||
/// <param name="startTime">Initial time.</param>
|
||||
public FakeTimeProvider(DateTimeOffset startTime)
|
||||
{
|
||||
_now = startTime;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">Duration to advance.</param>
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_now += duration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current time to the specified value.
|
||||
/// </summary>
|
||||
/// <param name="time">New current time.</param>
|
||||
public void SetTime(DateTimeOffset time)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_now = time;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of seconds.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Number of seconds to advance.</param>
|
||||
public void AdvanceSeconds(double seconds) => Advance(TimeSpan.FromSeconds(seconds));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of minutes.
|
||||
/// </summary>
|
||||
/// <param name="minutes">Number of minutes to advance.</param>
|
||||
public void AdvanceMinutes(double minutes) => Advance(TimeSpan.FromMinutes(minutes));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of hours.
|
||||
/// </summary>
|
||||
/// <param name="hours">Number of hours to advance.</param>
|
||||
public void AdvanceHours(double hours) => Advance(TimeSpan.FromHours(hours));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of days.
|
||||
/// </summary>
|
||||
/// <param name="days">Number of days to advance.</param>
|
||||
public void AdvanceDays(double days) => Advance(TimeSpan.FromDays(days));
|
||||
}
|
||||
72
src/Plugin/StellaOps.Plugin.Testing/PluginTestBase.cs
Normal file
72
src/Plugin/StellaOps.Plugin.Testing/PluginTestBase.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit test base class for plugin testing.
|
||||
/// Provides automatic setup and teardown of plugin test infrastructure.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPlugin">The plugin type to test.</typeparam>
|
||||
public abstract class PluginTestBase<TPlugin> : IAsyncLifetime where TPlugin : IPlugin, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the test host.
|
||||
/// </summary>
|
||||
protected PluginTestHost Host { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin under test.
|
||||
/// </summary>
|
||||
protected TPlugin Plugin { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test context.
|
||||
/// </summary>
|
||||
protected TestPluginContext Context => Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test logger.
|
||||
/// </summary>
|
||||
protected TestPluginLogger Logger => Context.Logger;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test configuration.
|
||||
/// </summary>
|
||||
protected TestPluginConfiguration Configuration => Context.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fake time provider.
|
||||
/// </summary>
|
||||
protected FakeTimeProvider? FakeTimeProvider => Context.TimeProvider as FakeTimeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP client factory.
|
||||
/// </summary>
|
||||
protected TestHttpClientFactory HttpClientFactory => (TestHttpClientFactory)Context.HttpClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Override to configure the test host.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options to configure.</param>
|
||||
protected virtual void ConfigureHost(PluginTestHostOptions options) { }
|
||||
|
||||
/// <summary>
|
||||
/// Override to provide configuration for the plugin.
|
||||
/// </summary>
|
||||
/// <returns>Configuration dictionary.</returns>
|
||||
protected virtual Dictionary<string, object> GetConfiguration() => new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask InitializeAsync()
|
||||
{
|
||||
Host = new PluginTestHost(ConfigureHost);
|
||||
Plugin = await Host.LoadPluginAsync<TPlugin>(GetConfiguration());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
await Host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Testing/PluginTestHost.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Testing/PluginTestHost.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Test host for running plugins in isolation during testing.
|
||||
/// </summary>
|
||||
public sealed class PluginTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<IPlugin> _plugins = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test context for assertions.
|
||||
/// </summary>
|
||||
public TestPluginContext Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin test host.
|
||||
/// </summary>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
public PluginTestHost(Action<PluginTestHostOptions>? configure = null)
|
||||
{
|
||||
var options = new PluginTestHostOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
Context = new TestPluginContext(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load and initialize a plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Initialized plugin instance.</returns>
|
||||
public async Task<T> LoadPluginAsync<T>(CancellationToken ct = default) where T : IPlugin, new()
|
||||
{
|
||||
var plugin = new T();
|
||||
await plugin.InitializeAsync(Context, ct);
|
||||
_plugins.Add(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load and initialize a plugin with custom configuration.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="configuration">Configuration values to set.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Initialized plugin instance.</returns>
|
||||
public async Task<T> LoadPluginAsync<T>(
|
||||
Dictionary<string, object> configuration,
|
||||
CancellationToken ct = default) where T : IPlugin, new()
|
||||
{
|
||||
foreach (var (key, value) in configuration)
|
||||
{
|
||||
Context.Configuration.SetValue(key, value);
|
||||
}
|
||||
|
||||
return await LoadPluginAsync<T>(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify plugin health.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="plugin">Plugin to check.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync<T>(T plugin, CancellationToken ct = default)
|
||||
where T : IPlugin
|
||||
{
|
||||
return await plugin.HealthCheckAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var plugin in _plugins)
|
||||
{
|
||||
await plugin.DisposeAsync();
|
||||
}
|
||||
_plugins.Clear();
|
||||
}
|
||||
}
|
||||
39
src/Plugin/StellaOps.Plugin.Testing/PluginTestHostOptions.cs
Normal file
39
src/Plugin/StellaOps.Plugin.Testing/PluginTestHostOptions.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the plugin test host.
|
||||
/// </summary>
|
||||
public sealed class PluginTestHostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enable logging output.
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum log level to capture.
|
||||
/// </summary>
|
||||
public LogLevel MinLogLevel { get; set; } = LogLevel.Debug;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom time provider for deterministic testing.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret values for testing.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Secrets { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Configuration values for testing.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> Configuration { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Start time for fake time provider (if not using custom TimeProvider).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Sequential GUID generator for deterministic testing.
|
||||
/// Generates GUIDs in a predictable sequence based on a counter.
|
||||
/// </summary>
|
||||
public sealed class SequentialGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sequential GUID generator.
|
||||
/// </summary>
|
||||
public SequentialGuidGenerator()
|
||||
{
|
||||
_counter = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sequential GUID generator starting at the specified counter value.
|
||||
/// </summary>
|
||||
/// <param name="startValue">Initial counter value.</param>
|
||||
public SequentialGuidGenerator(int startValue)
|
||||
{
|
||||
_counter = startValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new sequential GUID.
|
||||
/// </summary>
|
||||
public Guid NewGuid()
|
||||
{
|
||||
var counter = Interlocked.Increment(ref _counter);
|
||||
var bytes = new byte[16];
|
||||
|
||||
// Put counter in first 4 bytes for easy debugging
|
||||
BitConverter.GetBytes(counter).CopyTo(bytes, 0);
|
||||
|
||||
// Fill rest with deterministic pattern
|
||||
bytes[4] = 0x00;
|
||||
bytes[5] = 0x00;
|
||||
bytes[6] = 0x40; // Version 4
|
||||
bytes[7] = 0x00;
|
||||
bytes[8] = 0x80; // Variant
|
||||
bytes[9] = 0x00;
|
||||
bytes[10] = 0x00;
|
||||
bytes[11] = 0x00;
|
||||
bytes[12] = 0x00;
|
||||
bytes[13] = 0x00;
|
||||
bytes[14] = 0x00;
|
||||
bytes[15] = 0x00;
|
||||
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the counter to zero.
|
||||
/// </summary>
|
||||
public void Reset() => _counter = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Resets the counter to the specified value.
|
||||
/// </summary>
|
||||
/// <param name="value">New counter value.</param>
|
||||
public void Reset(int value) => _counter = value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current counter value.
|
||||
/// </summary>
|
||||
public int CurrentValue => _counter;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Testing utilities for Stella Ops plugins</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
224
src/Plugin/StellaOps.Plugin.Testing/TestHttpClientFactory.cs
Normal file
224
src/Plugin/StellaOps.Plugin.Testing/TestHttpClientFactory.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Test HTTP client factory with request recording and response mocking.
|
||||
/// </summary>
|
||||
public sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly Dictionary<string, MockHttpMessageHandler> _handlers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<RecordedRequest> _requests = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recorded requests across all clients.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RecordedRequest> RecordedRequests
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _requests.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handlers.TryGetValue(name, out var handler))
|
||||
{
|
||||
handler = new MockHttpMessageHandler(_requests, _lock);
|
||||
_handlers[name] = handler;
|
||||
}
|
||||
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="response">Response to return.</param>
|
||||
public void SetupResponse(string clientName, string urlPattern, HttpResponseMessage response)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handlers.TryGetValue(clientName, out var handler))
|
||||
{
|
||||
handler = new MockHttpMessageHandler(_requests, _lock);
|
||||
_handlers[clientName] = handler;
|
||||
}
|
||||
|
||||
handler.SetupResponse(urlPattern, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a JSON response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="json">JSON content to return.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public void SetupJsonResponse(
|
||||
string clientName,
|
||||
string urlPattern,
|
||||
string json,
|
||||
HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
|
||||
};
|
||||
SetupResponse(clientName, urlPattern, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up an error response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
/// <param name="reasonPhrase">Optional reason phrase.</param>
|
||||
public void SetupError(
|
||||
string clientName,
|
||||
string urlPattern,
|
||||
HttpStatusCode statusCode,
|
||||
string? reasonPhrase = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode)
|
||||
{
|
||||
ReasonPhrase = reasonPhrase
|
||||
};
|
||||
SetupResponse(clientName, urlPattern, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded requests.
|
||||
/// </summary>
|
||||
public void ClearRecordedRequests()
|
||||
{
|
||||
lock (_lock) _requests.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all handlers and recorded requests.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_handlers.Clear();
|
||||
_requests.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recorded HTTP request.
|
||||
/// </summary>
|
||||
/// <param name="Method">HTTP method.</param>
|
||||
/// <param name="Uri">Request URI.</param>
|
||||
/// <param name="Headers">Request headers.</param>
|
||||
/// <param name="Content">Request content.</param>
|
||||
/// <param name="Timestamp">When the request was made.</param>
|
||||
public sealed record RecordedRequest(
|
||||
HttpMethod Method,
|
||||
Uri? Uri,
|
||||
Dictionary<string, IEnumerable<string>> Headers,
|
||||
string? Content,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
internal sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<RecordedRequest> _requests;
|
||||
private readonly object _lock;
|
||||
private readonly Dictionary<string, HttpResponseMessage> _responses = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MockHttpMessageHandler(List<RecordedRequest> requests, object lockObj)
|
||||
{
|
||||
_requests = requests;
|
||||
_lock = lockObj;
|
||||
}
|
||||
|
||||
public void SetupResponse(string urlPattern, HttpResponseMessage response)
|
||||
{
|
||||
_responses[urlPattern] = response;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Record the request
|
||||
string? content = null;
|
||||
if (request.Content != null)
|
||||
{
|
||||
content = await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var headers = request.Headers.ToDictionary(
|
||||
h => h.Key,
|
||||
h => h.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var recorded = new RecordedRequest(
|
||||
request.Method,
|
||||
request.RequestUri,
|
||||
headers,
|
||||
content,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_requests.Add(recorded);
|
||||
}
|
||||
|
||||
// Find matching response
|
||||
var url = request.RequestUri?.ToString() ?? "";
|
||||
foreach (var (pattern, response) in _responses)
|
||||
{
|
||||
if (url.Contains(pattern, StringComparison.OrdinalIgnoreCase) ||
|
||||
pattern == "*" ||
|
||||
System.Text.RegularExpressions.Regex.IsMatch(url, pattern))
|
||||
{
|
||||
// Clone response to allow reuse
|
||||
return await CloneResponseAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
ReasonPhrase = $"No mock response configured for {url}"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> CloneResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
var clone = new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
ReasonPhrase = response.ReasonPhrase,
|
||||
Version = response.Version
|
||||
};
|
||||
|
||||
if (response.Content != null)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
clone.Content = new StringContent(
|
||||
content,
|
||||
System.Text.Encoding.UTF8,
|
||||
response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
124
src/Plugin/StellaOps.Plugin.Testing/TestPluginConfiguration.cs
Normal file
124
src/Plugin/StellaOps.Plugin.Testing/TestPluginConfiguration.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Sdk;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of plugin configuration.
|
||||
/// </summary>
|
||||
public sealed class TestPluginConfiguration : IPluginConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, object> _values;
|
||||
private readonly Dictionary<string, string> _secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test configuration.
|
||||
/// </summary>
|
||||
/// <param name="values">Initial configuration values.</param>
|
||||
/// <param name="secrets">Initial secret values.</param>
|
||||
public TestPluginConfiguration(
|
||||
Dictionary<string, object> values,
|
||||
Dictionary<string, string> secrets)
|
||||
{
|
||||
_values = new Dictionary<string, object>(values, StringComparer.OrdinalIgnoreCase);
|
||||
_secrets = new Dictionary<string, string>(secrets, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetValue<T>(string key, T? defaultValue = default)
|
||||
{
|
||||
if (!_values.TryGetValue(key, out var value))
|
||||
return defaultValue;
|
||||
|
||||
if (value == null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||
return (T)Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a configuration value.
|
||||
/// </summary>
|
||||
/// <param name="key">Configuration key.</param>
|
||||
/// <param name="value">Configuration value.</param>
|
||||
public void SetValue(string key, object value)
|
||||
{
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Bind<T>(string? sectionKey = null) where T : class, new()
|
||||
{
|
||||
var result = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
var prefix = string.IsNullOrEmpty(sectionKey) ? "" : sectionKey + ":";
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (!prop.CanWrite) continue;
|
||||
|
||||
var key = prefix + prop.Name;
|
||||
var configAttr = prop.GetCustomAttribute<PluginConfigAttribute>();
|
||||
if (configAttr?.Key != null)
|
||||
key = prefix + configAttr.Key;
|
||||
|
||||
if (_values.TryGetValue(key, out var value))
|
||||
{
|
||||
try
|
||||
{
|
||||
var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
var convertedValue = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
prop.SetValue(result, convertedValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip properties that can't be converted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_secrets.TryGetValue(secretName, out var secret) ? secret : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasKey(string key)
|
||||
{
|
||||
return _values.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="value">Secret value.</param>
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
_secrets[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all configuration values.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_values.Clear();
|
||||
_secrets.Clear();
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginContext.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginContext.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of IPluginContext.
|
||||
/// </summary>
|
||||
public sealed class TestPluginContext : IPluginContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the test configuration.
|
||||
/// </summary>
|
||||
public TestPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test logger.
|
||||
/// </summary>
|
||||
public TestPluginLogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test services.
|
||||
/// </summary>
|
||||
public TestPluginServices Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken ShutdownToken { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GUID generator for deterministic testing.
|
||||
/// </summary>
|
||||
public SequentialGuidGenerator GuidGenerator { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP client factory for test mocking.
|
||||
/// </summary>
|
||||
public TestHttpClientFactory HttpClientFactory { get; }
|
||||
|
||||
IPluginConfiguration IPluginContext.Configuration => Configuration;
|
||||
IPluginLogger IPluginContext.Logger => Logger;
|
||||
IPluginServices IPluginContext.Services => Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test context.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options.</param>
|
||||
public TestPluginContext(PluginTestHostOptions options)
|
||||
{
|
||||
Configuration = new TestPluginConfiguration(options.Configuration, options.Secrets);
|
||||
Logger = new TestPluginLogger(options.MinLogLevel, options.EnableLogging);
|
||||
Services = new TestPluginServices();
|
||||
|
||||
TimeProvider = options.TimeProvider
|
||||
?? new FakeTimeProvider(options.StartTime ?? DateTimeOffset.UtcNow);
|
||||
|
||||
GuidGenerator = new SequentialGuidGenerator();
|
||||
HttpClientFactory = new TestHttpClientFactory();
|
||||
|
||||
InstanceId = GuidGenerator.NewGuid();
|
||||
ShutdownToken = CancellationToken.None;
|
||||
|
||||
// Register common services
|
||||
Services.Register<IHttpClientFactory>(HttpClientFactory);
|
||||
Services.Register(TimeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test context with a custom shutdown token.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options.</param>
|
||||
/// <param name="shutdownToken">Shutdown cancellation token.</param>
|
||||
public TestPluginContext(PluginTestHostOptions options, CancellationToken shutdownToken)
|
||||
: this(options)
|
||||
{
|
||||
ShutdownToken = shutdownToken;
|
||||
}
|
||||
}
|
||||
200
src/Plugin/StellaOps.Plugin.Testing/TestPluginLogger.cs
Normal file
200
src/Plugin/StellaOps.Plugin.Testing/TestPluginLogger.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Test logger that captures log entries for assertions.
|
||||
/// </summary>
|
||||
public sealed class TestPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly LogLevel _minLevel;
|
||||
private readonly bool _enabled;
|
||||
private readonly List<LogEntry> _entries = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, object> _properties = new();
|
||||
private readonly string? _operationName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured log entries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogEntry> Entries
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _entries.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test logger.
|
||||
/// </summary>
|
||||
/// <param name="minLevel">Minimum log level to capture.</param>
|
||||
/// <param name="enabled">Whether logging is enabled.</param>
|
||||
public TestPluginLogger(LogLevel minLevel, bool enabled)
|
||||
{
|
||||
_minLevel = minLevel;
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
private TestPluginLogger(
|
||||
LogLevel minLevel,
|
||||
bool enabled,
|
||||
List<LogEntry> entries,
|
||||
object lockObj,
|
||||
Dictionary<string, object> properties,
|
||||
string? operationName)
|
||||
{
|
||||
_minLevel = minLevel;
|
||||
_enabled = enabled;
|
||||
_entries = entries;
|
||||
_lock = lockObj;
|
||||
_properties = new Dictionary<string, object>(properties);
|
||||
_operationName = operationName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
if (!_enabled || level < _minLevel) return;
|
||||
|
||||
var formatted = FormatMessage(message, args);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(new LogEntry(level, formatted, null, _operationName, new Dictionary<string, object>(_properties)));
|
||||
}
|
||||
|
||||
if (_enabled)
|
||||
{
|
||||
var prefix = _operationName != null ? $"[{_operationName}] " : "";
|
||||
Console.WriteLine($"[{level}] {prefix}{formatted}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
if (!_enabled || level < _minLevel) return;
|
||||
|
||||
var formatted = FormatMessage(message, args);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(new LogEntry(level, formatted, exception, _operationName, new Dictionary<string, object>(_properties)));
|
||||
}
|
||||
|
||||
if (_enabled)
|
||||
{
|
||||
var prefix = _operationName != null ? $"[{_operationName}] " : "";
|
||||
Console.WriteLine($"[{level}] {prefix}{formatted}");
|
||||
Console.WriteLine(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Info(string message, params object[] args) => Log(LogLevel.Information, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Error(string message, params object[] args) => Log(LogLevel.Error, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Error(Exception ex, string message, params object[] args) => Log(LogLevel.Error, ex, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProps = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, newProps, _operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, _properties, operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel level) => _enabled && level >= _minLevel;
|
||||
|
||||
private static string FormatMessage(string message, object[] args)
|
||||
{
|
||||
if (args.Length == 0) return message;
|
||||
|
||||
// Handle structured logging placeholders like {PluginId}
|
||||
var formatted = message;
|
||||
var argIndex = 0;
|
||||
formatted = System.Text.RegularExpressions.Regex.Replace(
|
||||
formatted,
|
||||
@"\{[^}]+\}",
|
||||
match =>
|
||||
{
|
||||
if (argIndex < args.Length)
|
||||
{
|
||||
var arg = args[argIndex++];
|
||||
return arg?.ToString() ?? "null";
|
||||
}
|
||||
return match.Value;
|
||||
},
|
||||
System.Text.RegularExpressions.RegexOptions.None,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any entries were logged at the specified level.
|
||||
/// </summary>
|
||||
/// <param name="level">Log level to check.</param>
|
||||
public bool HasLoggedAtLevel(LogLevel level) => Entries.Any(e => e.Level == level);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any error was logged.
|
||||
/// </summary>
|
||||
public bool HasLoggedError() => HasLoggedAtLevel(LogLevel.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any warning was logged.
|
||||
/// </summary>
|
||||
public bool HasLoggedWarning() => HasLoggedAtLevel(LogLevel.Warning);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a message containing the specified text was logged.
|
||||
/// </summary>
|
||||
/// <param name="text">Text to search for.</param>
|
||||
public bool HasLoggedContaining(string text) =>
|
||||
Entries.Any(e => e.Message.Contains(text, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured log entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) _entries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A captured log entry.
|
||||
/// </summary>
|
||||
/// <param name="Level">Log level.</param>
|
||||
/// <param name="Message">Formatted message.</param>
|
||||
/// <param name="Exception">Optional exception.</param>
|
||||
/// <param name="OperationName">Optional operation name.</param>
|
||||
/// <param name="Properties">Scoped properties.</param>
|
||||
public sealed record LogEntry(
|
||||
LogLevel Level,
|
||||
string Message,
|
||||
Exception? Exception,
|
||||
string? OperationName = null,
|
||||
Dictionary<string, object>? Properties = null);
|
||||
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginServices.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginServices.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of plugin services.
|
||||
/// </summary>
|
||||
public sealed class TestPluginServices : IPluginServices
|
||||
{
|
||||
private readonly Dictionary<Type, object> _services = new();
|
||||
private readonly Dictionary<Type, List<object>> _multiServices = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a service implementation.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Service type.</typeparam>
|
||||
/// <param name="implementation">Service implementation.</param>
|
||||
public void Register<T>(T implementation) where T : class
|
||||
{
|
||||
_services[typeof(T)] = implementation;
|
||||
|
||||
if (!_multiServices.TryGetValue(typeof(T), out var list))
|
||||
{
|
||||
list = new List<object>();
|
||||
_multiServices[typeof(T)] = list;
|
||||
}
|
||||
list.Add(implementation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T GetRequiredService<T>() where T : class
|
||||
{
|
||||
if (_services.TryGetValue(typeof(T), out var service))
|
||||
{
|
||||
return (T)service;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Service {typeof(T).Name} is not registered");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetService<T>() where T : class
|
||||
{
|
||||
if (_services.TryGetValue(typeof(T), out var service))
|
||||
{
|
||||
return (T)service;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<T> GetServices<T>() where T : class
|
||||
{
|
||||
if (_multiServices.TryGetValue(typeof(T), out var list))
|
||||
{
|
||||
return list.Cast<T>();
|
||||
}
|
||||
|
||||
return Enumerable.Empty<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncDisposable CreateScope(out IPluginServices scopedServices)
|
||||
{
|
||||
// For testing, just return a copy of this service provider
|
||||
var scope = new TestPluginServices();
|
||||
foreach (var (type, service) in _services)
|
||||
{
|
||||
scope._services[type] = service;
|
||||
}
|
||||
foreach (var (type, list) in _multiServices)
|
||||
{
|
||||
scope._multiServices[type] = new List<object>(list);
|
||||
}
|
||||
|
||||
scopedServices = scope;
|
||||
return new TestScope();
|
||||
}
|
||||
|
||||
private sealed class TestScope : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user