diff --git a/Directory.Packages.props b/Directory.Packages.props index 39c528323..f7dd25b23 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,6 +41,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/HealthChecks.slnx b/HealthChecks.slnx index cd3011c12..d84fa953f 100644 --- a/HealthChecks.slnx +++ b/HealthChecks.slnx @@ -47,6 +47,7 @@ + diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/DependencyInjectionExtensions.cs b/src/NetEvolve.HealthChecks.GCP.Firestore/DependencyInjectionExtensions.cs new file mode 100644 index 000000000..9a4c2c3fa --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/DependencyInjectionExtensions.cs @@ -0,0 +1,63 @@ +namespace NetEvolve.HealthChecks.GCP.Firestore; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NetEvolve.HealthChecks.Abstractions; + +/// +/// Extension methods for registering Firestore health checks. +/// +public static class DependencyInjectionExtensions +{ + private static readonly string[] _defaultTags = ["firestore", "gcp"]; + + /// + /// Adds a health check for Google Cloud Firestore. + /// + /// The . + /// The name of the health check. + /// An optional action to configure the . + /// An optional list of tags to associate with the health check. + /// The so that additional calls can be chained. + /// Thrown when is . + /// Thrown when is or empty. + /// Thrown when is already in use. + /// Thrown when is . + public static IHealthChecksBuilder AddFirestore( + [NotNull] this IHealthChecksBuilder builder, + [NotNull] string name, + Action? options = null, + params string[] tags + ) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(tags); + + if (!builder.IsServiceTypeRegistered()) + { + _ = builder + .Services.AddSingleton() + .AddSingleton() + .ConfigureOptions(); + } + + builder.ThrowIfNameIsAlreadyUsed(name); + + if (options is not null) + { + _ = builder.Services.Configure(name, options); + } + + return builder.AddCheck( + name, + HealthStatus.Unhealthy, + _defaultTags.Union(tags, StringComparer.OrdinalIgnoreCase) + ); + } + + private sealed partial class FirestoreHealthCheckMarker; +} diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreHealthCheck.cs b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreHealthCheck.cs new file mode 100644 index 000000000..326aa5011 --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreHealthCheck.cs @@ -0,0 +1,44 @@ +namespace NetEvolve.HealthChecks.GCP.Firestore; + +using System; +using System.Threading; +using System.Threading.Tasks; +using global::Google.Cloud.Firestore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using NetEvolve.Extensions.Tasks; +using NetEvolve.HealthChecks.Abstractions; + +internal sealed class FirestoreHealthCheck : ConfigurableHealthCheckBase +{ + private readonly IServiceProvider _serviceProvider; + + public FirestoreHealthCheck(IOptionsMonitor optionsMonitor, IServiceProvider serviceProvider) + : base(optionsMonitor) => _serviceProvider = serviceProvider; + + protected override async ValueTask ExecuteHealthCheckAsync( + string name, + HealthStatus failureStatus, + FirestoreOptions options, + CancellationToken cancellationToken + ) + { + var client = string.IsNullOrWhiteSpace(options.KeyedService) + ? _serviceProvider.GetRequiredService() + : _serviceProvider.GetRequiredKeyedService(options.KeyedService); + + // Use a simple operation to check if the database is accessible + // Creating a document reference is a local operation that validates the client is configured + var testCollection = client.Collection("healthcheck"); + var testDoc = testCollection.Document("test"); + + // Attempt a lightweight operation with timeout + var (isValid, _) = await testDoc + .GetSnapshotAsync(cancellationToken) + .WithTimeoutAsync(options.Timeout, cancellationToken) + .ConfigureAwait(false); + + return HealthCheckState(isValid, name); + } +} diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptions.cs b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptions.cs new file mode 100644 index 000000000..6a7f9585a --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptions.cs @@ -0,0 +1,23 @@ +namespace NetEvolve.HealthChecks.GCP.Firestore; + +/// +/// Options for +/// +public sealed record FirestoreOptions +{ + /// + /// Gets or sets the timeout in milliseconds to use when executing tasks against the Firestore database. + /// + /// + /// The timeout in milliseconds. Default value is 100 milliseconds. + /// + public int Timeout { get; set; } = 100; + + /// + /// Gets or sets the keyed service name for retrieving the instance. + /// + /// + /// The keyed service name, or null if using the default service registration. + /// + public string? KeyedService { get; set; } +} diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptionsConfigure.cs b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptionsConfigure.cs new file mode 100644 index 000000000..5f4be7783 --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/FirestoreOptionsConfigure.cs @@ -0,0 +1,43 @@ +namespace NetEvolve.HealthChecks.GCP.Firestore; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using static Microsoft.Extensions.Options.ValidateOptionsResult; + +internal sealed class FirestoreOptionsConfigure + : IConfigureNamedOptions, + IValidateOptions +{ + private readonly IConfiguration _configuration; + + public FirestoreOptionsConfigure(IConfiguration configuration) => _configuration = configuration; + + public void Configure(string? name, FirestoreOptions options) + { + ArgumentNullException.ThrowIfNull(name); + + _configuration.Bind($"HealthChecks:GCP:Firestore:{name}", options); + } + + public void Configure(FirestoreOptions options) => Configure(Options.DefaultName, options); + + public ValidateOptionsResult Validate(string? name, FirestoreOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + return Fail("The name cannot be null or whitespace."); + } + + if (options is null) + { + return Fail("The option cannot be null."); + } + + if (options.Timeout < Timeout.Infinite) + { + return Fail("The timeout value must be a positive number in milliseconds or -1 for an infinite timeout."); + } + + return Success; + } +} diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/NetEvolve.HealthChecks.GCP.Firestore.csproj b/src/NetEvolve.HealthChecks.GCP.Firestore/NetEvolve.HealthChecks.GCP.Firestore.csproj new file mode 100644 index 000000000..911e159d5 --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/NetEvolve.HealthChecks.GCP.Firestore.csproj @@ -0,0 +1,14 @@ + + + $(_ProjectTargetFrameworks) + Contains HealthChecks for Google Cloud Platform Firestore, based on the nuget package `Google.Cloud.Firestore`. + $(PackageTags);gcp;google;firestore + + + + + + + + + diff --git a/src/NetEvolve.HealthChecks.GCP.Firestore/README.md b/src/NetEvolve.HealthChecks.GCP.Firestore/README.md new file mode 100644 index 000000000..40364658e --- /dev/null +++ b/src/NetEvolve.HealthChecks.GCP.Firestore/README.md @@ -0,0 +1,78 @@ +# NetEvolve.HealthChecks.GCP.Firestore + +[![NuGet](https://img.shields.io/nuget/v/NetEvolve.HealthChecks.GCP.Firestore?logo=nuget)](https://www.nuget.org/packages/NetEvolve.HealthChecks.GCP.Firestore/) +[![NuGet](https://img.shields.io/nuget/dt/NetEvolve.HealthChecks.GCP.Firestore?logo=nuget)](https://www.nuget.org/packages/NetEvolve.HealthChecks.GCP.Firestore/) + +This package provides a health check for Google Cloud Platform Firestore, based on the [Google.Cloud.Firestore](https://www.nuget.org/packages/Google.Cloud.Firestore/) package. The main purpose is to check if the Firestore database is available and accessible. + +:bulb: This package is available for .NET 8.0 and later. + +## Installation +To use this package, you need to add the package to your project. You can do this by using the NuGet package manager or by using the dotnet CLI. +```powershell +dotnet add package NetEvolve.HealthChecks.GCP.Firestore +``` + +## Health Check - Firestore Liveness +The health check is a liveness check. It checks if the Firestore database is available and accessible. +If the query needs longer than the configured timeout, the health check will return `Degraded`. +If the query fails, for whatever reason, the health check will return `Unhealthy`. + +### Usage +After adding the package, you need to import the namespace and add the health check to the health check builder. +```csharp +using NetEvolve.HealthChecks.GCP.Firestore; +``` +Therefore, you can use two different approaches. In both approaches you have to provide a name for the health check. + +### Parameters +- `name`: The name of the health check. The name is used to identify the configuration object. It is required and must be unique within the application. +- `options`: The configuration options for the health check. If you don't provide any options, the health check will use the configuration based approach. +- `tags`: The tags for the health check. The tags `firestore` and `gcp` are always used as default and combined with the user input. You can provide additional tags to group or filter the health checks. + +### Variant 1: Configuration based +The first one is to use the configuration based approach. This approach is recommended if you have multiple Firestore instances to check. +```csharp +var builder = services.AddHealthChecks(); + +builder.AddFirestore(""); +``` + +The configuration looks like this: +```json +{ + ..., // other configuration + "HealthChecks": { + "GCP": { + "Firestore": { + "": { + "Timeout": // optional, default is 100 milliseconds + } + } + } + } +} +``` + +### Variant 2: Builder based +The second approach is to use the builder based approach. This approach is recommended if you only have one Firestore instance to check or dynamic programmatic values. +```csharp +var builder = services.AddHealthChecks(); + +builder.AddFirestore("", options => +{ + options.Timeout = ; // optional, default is 100 milliseconds +}); +``` + +### :bulb: You can always provide tags to all health checks, for grouping or filtering. + +```csharp +var builder = services.AddHealthChecks(); + +builder.AddFirestore("", options => ..., "firestore", "gcp"); +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](https://raw.githubusercontent.com/dailydevops/healthchecks/refs/heads/main/LICENSE) file for details. diff --git a/tests/NetEvolve.HealthChecks.Tests.Architecture/HealthCheckArchitecture.cs b/tests/NetEvolve.HealthChecks.Tests.Architecture/HealthCheckArchitecture.cs index bbbf2f3f6..9a8f7783d 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Architecture/HealthCheckArchitecture.cs +++ b/tests/NetEvolve.HealthChecks.Tests.Architecture/HealthCheckArchitecture.cs @@ -34,6 +34,8 @@ private static Architecture LoadArchitecture() typeof(Azure.Queues.QueueClientAvailableHealthCheck).Assembly, typeof(Azure.ServiceBus.ServiceBusQueueHealthCheck).Assembly, typeof(Azure.Tables.TableClientAvailableHealthCheck).Assembly, + // GCP + typeof(GCP.Firestore.FirestoreHealthCheck).Assembly, // others typeof(Abstractions.HealthCheckBase).Assembly, typeof(ArangoDb.ArangoDbHealthCheck).Assembly, diff --git a/tests/NetEvolve.HealthChecks.Tests.Architecture/NetEvolve.HealthChecks.Tests.Architecture.csproj b/tests/NetEvolve.HealthChecks.Tests.Architecture/NetEvolve.HealthChecks.Tests.Architecture.csproj index e29fd8684..426698130 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Architecture/NetEvolve.HealthChecks.Tests.Architecture.csproj +++ b/tests/NetEvolve.HealthChecks.Tests.Architecture/NetEvolve.HealthChecks.Tests.Architecture.csproj @@ -36,6 +36,7 @@ + diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.SNS/SimpleNotificationServiceHealthCheckTests.cs b/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.SNS/SimpleNotificationServiceHealthCheckTests.cs index d4bb5bd69..f01af0951 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.SNS/SimpleNotificationServiceHealthCheckTests.cs +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.SNS/SimpleNotificationServiceHealthCheckTests.cs @@ -121,6 +121,7 @@ await RunAndVerify( options.TopicName = topicName; options.Subscription = subcription.SubscriptionArn; options.Mode = CreationMode.BasicAuthentication; + options.Timeout = 10000; // Set a reasonable timeout } ); }, diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreDatabase.cs b/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreDatabase.cs new file mode 100644 index 000000000..2a1d8d842 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreDatabase.cs @@ -0,0 +1,43 @@ +namespace NetEvolve.HealthChecks.Tests.Integration.GCP.Firestore; + +using System; +using System.Threading.Tasks; +using global::Google.Cloud.Firestore; +using global::Google.Cloud.Firestore.V1; +using global::Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Testcontainers.Firestore; +using TUnit.Core.Interfaces; + +public sealed class FirestoreDatabase : IAsyncInitializer, IAsyncDisposable +{ + private readonly FirestoreContainer _container = new FirestoreBuilder().WithLogger(NullLogger.Instance).Build(); + + private FirestoreDb? _database; + + public const string ProjectId = "test-project"; + + public FirestoreDb Database => _database ?? throw new InvalidOperationException("Database not initialized"); + + public async ValueTask DisposeAsync() => await _container.DisposeAsync().ConfigureAwait(false); + + public async Task InitializeAsync() + { + await _container.StartAsync().ConfigureAwait(false); + + // Parse endpoint to get host:port + var fullEndpoint = _container.GetEmulatorEndpoint(); + var uri = new Uri(fullEndpoint); + var hostPort = $"{uri.Host}:{uri.Port}"; + + // Create Firestore client configured for emulator + var clientBuilder = new FirestoreClientBuilder + { + Endpoint = hostPort, + ChannelCredentials = ChannelCredentials.Insecure, + }; + + var client = await clientBuilder.BuildAsync().ConfigureAwait(false); + _database = await FirestoreDb.CreateAsync(ProjectId, client).ConfigureAwait(false); + } +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreHealthCheckTests.cs b/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreHealthCheckTests.cs new file mode 100644 index 000000000..3e32fa96a --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/GCP.Firestore/FirestoreHealthCheckTests.cs @@ -0,0 +1,101 @@ +namespace NetEvolve.HealthChecks.Tests.Integration.GCP.Firestore; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.GCP.Firestore; + +[TestGroup($"GCP.{nameof(Firestore)}")] +[ClassDataSource(Shared = InstanceSharedType.Firestore)] +public sealed class FirestoreHealthCheckTests : HealthCheckTestBase +{ + private readonly FirestoreDatabase _database; + + public FirestoreHealthCheckTests(FirestoreDatabase database) => _database = database; + + [Test] + public async Task AddFirestore_UseOptions_Healthy() => + await RunAndVerify( + healthChecks => healthChecks.AddFirestore("TestContainerHealthy", options => options.Timeout = 10000), + HealthStatus.Healthy, + serviceBuilder: services => _ = services.AddSingleton(_ => _database.Database) + ); + + [Test] + public async Task AddFirestore_UseOptions_Degraded() => + await RunAndVerify( + healthChecks => healthChecks.AddFirestore("TestContainerDegraded", options => options.Timeout = 0), + HealthStatus.Degraded, + serviceBuilder: services => _ = services.AddSingleton(_ => _database.Database) + ); + + [Test] + public async Task AddFirestore_UseOptionsWithKeyedService_Healthy() => + await RunAndVerify( + healthChecks => + { + _ = healthChecks.AddFirestore( + "TestContainerKeyedServiceHealthy", + options => + { + options.Timeout = 10000; + options.KeyedService = "firestore"; + } + ); + }, + HealthStatus.Healthy, + serviceBuilder: services => _ = services.AddKeyedSingleton("firestore", (_, _) => _database.Database) + ); + + [Test] + public async Task AddFirestore_UseConfiguration_Healthy() => + await RunAndVerify( + healthChecks => healthChecks.AddFirestore("TestContainerHealthy"), + HealthStatus.Healthy, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + { "HealthChecks:GCP:Firestore:TestContainerHealthy:Timeout", "10000" }, + }; + _ = config.AddInMemoryCollection(values); + }, + serviceBuilder: services => _ = services.AddSingleton(_ => _database.Database) + ); + + [Test] + public async Task AddFirestore_UseConfiguration_Degraded() => + await RunAndVerify( + healthChecks => healthChecks.AddFirestore("TestContainerDegraded"), + HealthStatus.Degraded, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + { "HealthChecks:GCP:Firestore:TestContainerDegraded:Timeout", "0" }, + }; + _ = config.AddInMemoryCollection(values); + }, + serviceBuilder: services => _ = services.AddSingleton(_ => _database.Database) + ); + + [Test] + public async Task AddFirestore_UseConfiguration_TimeoutMinusTwo_ThrowException() => + await RunAndVerify( + healthChecks => healthChecks.AddFirestore("TestNoValues"), + HealthStatus.Unhealthy, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + { "HealthChecks:GCP:Firestore:TestNoValues:Timeout", "-2" }, + }; + _ = config.AddInMemoryCollection(values); + }, + serviceBuilder: services => _ = services.AddSingleton(_ => _database.Database) + ); +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/Internals/InstanceSharedType.cs b/tests/NetEvolve.HealthChecks.Tests.Integration/Internals/InstanceSharedType.cs index bf6b53930..9c8b85152 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Integration/Internals/InstanceSharedType.cs +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/Internals/InstanceSharedType.cs @@ -29,6 +29,8 @@ internal static class InstanceSharedType public const SharedType Firebird = SharedType.PerClass; + public const SharedType Firestore = SharedType.PerClass; + public const SharedType Kafka = SharedType.PerClass; public const SharedType Keycloak = SharedType.PerClass; diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj b/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj index 98c4142b7..30512faf8 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj @@ -25,6 +25,7 @@ + @@ -69,6 +70,7 @@ + @@ -117,6 +119,7 @@ + diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Degraded.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Degraded.verified.txt new file mode 100644 index 000000000..4d60a96a1 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Degraded.verified.txt @@ -0,0 +1,14 @@ +{ + results: [ + { + description: TestContainerDegraded: Degraded, + name: TestContainerDegraded, + status: Degraded, + tags: [ + firestore, + gcp + ] + } + ], + status: Degraded +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Healthy.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Healthy.verified.txt new file mode 100644 index 000000000..08ac64c96 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_Healthy.verified.txt @@ -0,0 +1,14 @@ +{ + results: [ + { + description: TestContainerHealthy: Healthy, + name: TestContainerHealthy, + status: Healthy, + tags: [ + firestore, + gcp + ] + } + ], + status: Healthy +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_TimeoutMinusTwo_ThrowException.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_TimeoutMinusTwo_ThrowException.verified.txt new file mode 100644 index 000000000..d5cb53dd2 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseConfiguration_TimeoutMinusTwo_ThrowException.verified.txt @@ -0,0 +1,18 @@ +{ + results: [ + { + description: TestNoValues: Unexpected error., + exception: { + message: The timeout value must be a positive number in milliseconds or -1 for an infinite timeout., + type: Microsoft.Extensions.Options.OptionsValidationException + }, + name: TestNoValues, + status: Unhealthy, + tags: [ + firestore, + gcp + ] + } + ], + status: Unhealthy +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptionsWithKeyedService_Healthy.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptionsWithKeyedService_Healthy.verified.txt new file mode 100644 index 000000000..05603a958 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptionsWithKeyedService_Healthy.verified.txt @@ -0,0 +1,14 @@ +{ + results: [ + { + description: TestContainerKeyedServiceHealthy: Healthy, + name: TestContainerKeyedServiceHealthy, + status: Healthy, + tags: [ + firestore, + gcp + ] + } + ], + status: Healthy +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Degraded.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Degraded.verified.txt new file mode 100644 index 000000000..4d60a96a1 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Degraded.verified.txt @@ -0,0 +1,14 @@ +{ + results: [ + { + description: TestContainerDegraded: Degraded, + name: TestContainerDegraded, + status: Degraded, + tags: [ + firestore, + gcp + ] + } + ], + status: Degraded +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Healthy.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Healthy.verified.txt new file mode 100644 index 000000000..08ac64c96 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/FirestoreHealthCheck.AddFirestore_UseOptions_Healthy.verified.txt @@ -0,0 +1,14 @@ +{ + results: [ + { + description: TestContainerHealthy: Healthy, + name: TestContainerHealthy, + status: Healthy, + tags: [ + firestore, + gcp + ] + } + ], + status: Healthy +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/NetEvolve.HealthChecks.GCP.Firestore.PublicApi_HasNotChanged_Theory.verified.txt b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/NetEvolve.HealthChecks.GCP.Firestore.PublicApi_HasNotChanged_Theory.verified.txt new file mode 100644 index 000000000..3c5436702 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/_snapshots/NetEvolve.HealthChecks.GCP.Firestore.PublicApi_HasNotChanged_Theory.verified.txt @@ -0,0 +1,13 @@ +namespace NetEvolve.HealthChecks.GCP.Firestore +{ + public static class DependencyInjectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddFirestore([System.Diagnostics.CodeAnalysis.NotNull] this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, [System.Diagnostics.CodeAnalysis.NotNull] string name, System.Action? options = null, params string[] tags) { } + } + public sealed class FirestoreOptions : System.IEquatable + { + public FirestoreOptions() { } + public string? KeyedService { get; set; } + public int Timeout { get; set; } + } +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/DependencyInjectionExtensionsTests.cs b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/DependencyInjectionExtensionsTests.cs new file mode 100644 index 000000000..583df667b --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/DependencyInjectionExtensionsTests.cs @@ -0,0 +1,170 @@ +namespace NetEvolve.HealthChecks.Tests.Unit.GCP.Firestore; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.GCP.Firestore; + +[TestGroup($"GCP.{nameof(Firestore)}")] +public sealed class DependencyInjectionExtensionsTests +{ + [Test] + public void AddFirestore_WhenArgumentBuilderNull_ThrowArgumentNullException() + { + // Arrange + IHealthChecksBuilder builder = null!; + const string name = "Test"; + + // Act & Assert + _ = Assert.Throws(nameof(builder), () => builder.AddFirestore(name)); + } + + [Test] + public void AddFirestore_WhenArgumentNameNull_ThrowArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddHealthChecks(); + const string name = null!; + + // Act & Assert + _ = Assert.Throws(nameof(name), () => builder.AddFirestore(name)); + } + + [Test] + public void AddFirestore_WhenArgumentNameEmpty_ThrowArgumentException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddHealthChecks(); + const string name = ""; + + // Act & Assert + _ = Assert.Throws(nameof(name), () => builder.AddFirestore(name)); + } + + [Test] + public void AddFirestore_WhenArgumentTagsNull_ThrowArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddHealthChecks(); + const string name = "Test"; + + // Act & Assert + _ = Assert.Throws("tags", () => builder.AddFirestore(name, options: null, tags: null!)); + } + + [Test] + public async Task AddFirestore_WhenParameterCorrect_RegistrationAdded() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection([]).Build()); + var builder = services.AddHealthChecks(); + const string name = "Test"; + + // Act + _ = builder.AddFirestore(name); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = + serviceProvider.GetRequiredService>(); + var registration = options.Value.Registrations.FirstOrDefault(r => r.Name == name); + + _ = await Assert.That(registration).IsNotNull(); + } + + [Test] + public async Task AddFirestore_WhenParameterCorrectWithOptions_RegistrationAdded() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection([]).Build()); + var builder = services.AddHealthChecks(); + const string name = "Test"; + + // Act + _ = builder.AddFirestore( + name, + options => + { + options.Timeout = 10000; + options.KeyedService = "firestore"; + } + ); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = + serviceProvider.GetRequiredService>(); + var registration = options.Value.Registrations.FirstOrDefault(r => r.Name == name); + + _ = await Assert.That(registration).IsNotNull(); + } + + [Test] + public void AddFirestore_WhenDuplicateName_ThrowArgumentException() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection([]).Build()); + var builder = services.AddHealthChecks(); + const string name = "Test"; + + _ = builder.AddFirestore(name); + + // Act & Assert + _ = Assert.Throws(nameof(name), () => builder.AddFirestore(name)); + } + + [Test] + public async Task AddFirestore_WhenMultipleChecksWithDifferentNames_AllRegistrationsAdded() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection([]).Build()); + var builder = services.AddHealthChecks(); + + // Act + _ = builder.AddFirestore("Test1"); + _ = builder.AddFirestore("Test2"); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = + serviceProvider.GetRequiredService>(); + + _ = await Assert.That(options.Value.Registrations.Any(r => r.Name == "Test1")).IsTrue(); + _ = await Assert.That(options.Value.Registrations.Any(r => r.Name == "Test2")).IsTrue(); + } + + [Test] + public async Task AddFirestore_WhenCustomTags_TagsApplied() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection([]).Build()); + var builder = services.AddHealthChecks(); + const string name = "Test"; + const string customTag = "custom-tag"; + + // Act + _ = builder.AddFirestore(name, tags: customTag); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = + serviceProvider.GetRequiredService>(); + var registration = options.Value.Registrations.First(r => r.Name == name); + + _ = await Assert.That(registration.Tags.Contains(customTag)).IsTrue(); + _ = await Assert.That(registration.Tags.Contains("firestore")).IsTrue(); + _ = await Assert.That(registration.Tags.Contains("gcp")).IsTrue(); + } +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreConfigureTests.cs b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreConfigureTests.cs new file mode 100644 index 000000000..7b9998649 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreConfigureTests.cs @@ -0,0 +1,153 @@ +namespace NetEvolve.HealthChecks.Tests.Unit.GCP.Firestore; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.GCP.Firestore; + +[TestGroup($"GCP.{nameof(Firestore)}")] +public sealed class FirestoreConfigureTests +{ + [Test] + public void Configure_WhenArgumentNameNull_ThrowArgumentNullException() + { + // Arrange + var configure = new FirestoreOptionsConfigure(new ConfigurationBuilder().Build()); + const string? name = default; + var options = new FirestoreOptions(); + + // Act + void Act() => configure.Configure(name, options); + + // Assert + _ = Assert.Throws("name", Act); + } + + [Test] + public async Task Configure_WhenConfigurationValid_OptionsConfigured() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + { "HealthChecks:GCP:Firestore:Test:Timeout", "5000" }, + { "HealthChecks:GCP:Firestore:Test:KeyedService", "my-firestore" }, + } + ) + .Build(); + + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions(); + + // Act + configure.Configure("Test", options); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(options.Timeout).IsEqualTo(5000); + _ = await Assert.That(options.KeyedService).IsEqualTo("my-firestore"); + } + } + + [Test] + public async Task Configure_WhenConfigurationMissing_DefaultValuesUsed() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions(); + + // Act + configure.Configure("Test", options); + + // Assert + _ = await Assert.That(options.Timeout).IsEqualTo(100); + } + + [Test] + public async Task Configure_WhenTimeoutNegative_ValueSet() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { { "HealthChecks:GCP:Firestore:Test:Timeout", "-1" } } + ) + .Build(); + + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions(); + + // Act + configure.Configure("Test", options); + + // Assert + _ = await Assert.That(options.Timeout).IsEqualTo(-1); + } + + [Test] + public async Task Validate_WhenTimeoutLessThanMinusOne_ReturnsFailure() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions { Timeout = -2 }; + + // Act + var result = configure.Validate("Test", options); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(result.Failed).IsTrue(); + _ = await Assert.That(result.FailureMessage).Contains("timeout"); + } + } + + [Test] + public async Task Validate_WhenTimeoutValid_ReturnsSuccess() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions { Timeout = 1000 }; + + // Act + var result = configure.Validate("Test", options); + + // Assert + _ = await Assert.That(result.Succeeded).IsTrue(); + } + + [Test] + public async Task Validate_WhenNameNull_ReturnsFailure() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var configure = new FirestoreOptionsConfigure(config); + var options = new FirestoreOptions(); + + // Act + var result = configure.Validate(null, options); + + // Assert + _ = await Assert.That(result.Failed).IsTrue(); + } + + [Test] + public async Task Validate_WhenOptionsNull_ReturnsFailure() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var configure = new FirestoreOptionsConfigure(config); + + // Act + var result = configure.Validate("Test", null!); + + // Assert + _ = await Assert.That(result.Failed).IsTrue(); + } +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreHealthCheckTests.cs b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreHealthCheckTests.cs new file mode 100644 index 000000000..d66c0f4f7 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreHealthCheckTests.cs @@ -0,0 +1,72 @@ +namespace NetEvolve.HealthChecks.Tests.Unit.GCP.Firestore; + +using System; +using System.Threading; +using System.Threading.Tasks; +using global::Google.Cloud.Firestore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.GCP.Firestore; +using NSubstitute; + +[TestGroup($"GCP.{nameof(Firestore)}")] +public sealed class FirestoreHealthCheckTests +{ + [Test] + public async Task CheckHealthAsync_WhenContextNull_ThrowArgumentNullException() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var serviceProvider = Substitute.For(); + var check = new FirestoreHealthCheck(optionsMonitor, serviceProvider); + + // Act + async Task Act() => _ = await check.CheckHealthAsync(null!, default); + + // Assert + _ = await Assert.ThrowsAsync("context", Act); + } + + [Test] + public async Task CheckHealthAsync_WhenCancellationTokenIsCancelled_ShouldReturnUnhealthy() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var serviceProvider = Substitute.For(); + var check = new FirestoreHealthCheck(optionsMonitor, serviceProvider); + var context = new HealthCheckContext { Registration = new HealthCheckRegistration("Test", check, null, null) }; + var cancellationToken = new CancellationToken(true); + + // Act + var result = await check.CheckHealthAsync(context, cancellationToken); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(result.Status).IsEqualTo(HealthStatus.Unhealthy); + _ = await Assert.That(result.Description).IsEqualTo("Test: Cancellation requested."); + } + } + + [Test] + public async Task CheckHealthAsync_WhenOptionsAreNull_ShouldReturnUnhealthy() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var serviceProvider = Substitute.For(); + var check = new FirestoreHealthCheck(optionsMonitor, serviceProvider); + var context = new HealthCheckContext { Registration = new HealthCheckRegistration("Test", check, null, null) }; + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(result.Status).IsEqualTo(HealthStatus.Unhealthy); + _ = await Assert.That(result.Description).IsEqualTo("Test: Missing configuration."); + } + } +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreOptionsTests.cs b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreOptionsTests.cs new file mode 100644 index 000000000..546065860 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/GCP.Firestore/FirestoreOptionsTests.cs @@ -0,0 +1,65 @@ +namespace NetEvolve.HealthChecks.Tests.Unit.GCP.Firestore; + +using System.Threading.Tasks; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.GCP.Firestore; + +[TestGroup($"GCP.{nameof(Firestore)}")] +public sealed class FirestoreOptionsTests +{ + [Test] + public async Task Options_NotSame_Expected() + { + var options1 = new FirestoreOptions { Timeout = 100 }; + var options2 = options1 with { }; + + _ = await Assert.That(options1).IsEqualTo(options2).And.IsNotSameReferenceAs(options2); + } + + [Test] + public async Task Options_DefaultTimeout_Is100() + { + var options = new FirestoreOptions(); + + _ = await Assert.That(options.Timeout).IsEqualTo(100); + } + + [Test] + public async Task Options_WithCustomTimeout_TimeoutSet() + { + var options = new FirestoreOptions { Timeout = 5000 }; + + _ = await Assert.That(options.Timeout).IsEqualTo(5000); + } + + [Test] + public async Task Options_WithKeyedService_KeyedServiceSet() + { + const string keyedService = "my-firestore"; + var options = new FirestoreOptions { KeyedService = keyedService }; + + _ = await Assert.That(options.KeyedService).IsEqualTo(keyedService); + } + + [Test] + public async Task Options_WithClone_AllPropertiesCloned() + { + var options1 = new FirestoreOptions { Timeout = 5000, KeyedService = "test" }; + var options2 = options1 with { }; + + using (Assert.Multiple()) + { + _ = await Assert.That(options2.Timeout).IsEqualTo(options1.Timeout); + _ = await Assert.That(options2.KeyedService).IsEqualTo(options1.KeyedService); + } + } + + [Test] + public async Task Options_WithDifferentValues_NotEqual() + { + var options1 = new FirestoreOptions { Timeout = 100 }; + var options2 = new FirestoreOptions { Timeout = 200 }; + + _ = await Assert.That(options1).IsNotEqualTo(options2); + } +} diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj b/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj index f713e0a2d..2e25707fb 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj @@ -10,6 +10,7 @@ all + @@ -41,6 +42,7 @@ +