diff --git a/Directory.Packages.props b/Directory.Packages.props index 7e2bcb1..9dc1b10 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,7 +68,11 @@ + + + + diff --git a/tests/Healthie.Tests.Unit/ContainerFixtures.cs b/tests/Healthie.Tests.Unit/ContainerFixtures.cs new file mode 100644 index 0000000..09947ca --- /dev/null +++ b/tests/Healthie.Tests.Unit/ContainerFixtures.cs @@ -0,0 +1,105 @@ +using DotNet.Testcontainers.Containers; +using Testcontainers.CosmosDb; +using Testcontainers.MsSql; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; + +namespace Healthie.Tests.Unit; + +/// +/// Starts one container for a whole test class rather than one per test. +/// +/// +/// +/// xUnit builds a fresh instance of a test class for every test method, so a container started from +/// on the class is started once per test. That is what it was doing: +/// the CosmosDB emulator, which takes minutes to become ready, was started nine times for nine +/// tests and two of them timed out waiting. A class fixture is built once and shared, which is what +/// this is for. +/// +/// +/// A failure to start is recorded rather than thrown, so the tests can skip with the reason instead +/// of the whole class erroring on a machine with no container runtime. +/// +/// +/// The container this fixture owns. +public abstract class ContainerFixture : IAsyncLifetime + where TContainer : IContainer +{ + /// The container, once it has started. + protected TContainer? Container { get; private set; } + + /// Why the container is not usable, or null when it is. + public string? Unavailable { get; private set; } + + /// Builds the container. Called once. + protected abstract TContainer Build(); + + /// Anything that has to happen after the container is up, such as creating a schema. + protected virtual Task OnStartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async ValueTask InitializeAsync() + { + try + { + Container = Build(); + await Container.StartAsync(); + await OnStartedAsync(CancellationToken.None); + } + catch (Exception ex) + { + Unavailable = $"{ex.GetType().Name}: {ex.Message}"; + } + } + + public async ValueTask DisposeAsync() + { + if (Container is not null) + { + await Container.DisposeAsync(); + } + } +} + +/// One Redis for the Redis tests. +public sealed class RedisFixture : ContainerFixture +{ + protected override RedisContainer Build() => new RedisBuilder("redis:7-alpine").Build(); + + /// The connection string, once the container is up. + public string ConnectionString => Container!.GetConnectionString(); +} + +/// One PostgreSQL for the PostgreSQL tests. +public sealed class PostgresFixture : ContainerFixture +{ + protected override PostgreSqlContainer Build() => new PostgreSqlBuilder("postgres:17-alpine").Build(); + + /// The connection string, once the container is up. + public string ConnectionString => Container!.GetConnectionString(); +} + +/// One SQL Server for the SQL Server tests. +public sealed class SqlServerFixture : ContainerFixture +{ + protected override MsSqlContainer Build() => + new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + + /// The connection string, once the container is up. + public string ConnectionString => Container!.GetConnectionString(); +} + +/// +/// One CosmosDB emulator for the CosmosDB tests, which is the one that most needs sharing: it takes +/// minutes to become ready. +/// +public sealed class CosmosDbFixture : ContainerFixture +{ + protected override CosmosDbContainer Build() => new CosmosDbBuilder().Build(); + + /// The connection string, once the container is up. + public string ConnectionString => Container!.GetConnectionString(); + + /// The handler that accepts the emulator's self-signed certificate. + public HttpMessageHandler HttpMessageHandler => Container!.HttpMessageHandler; +} diff --git a/tests/Healthie.Tests.Unit/CosmosDbRealEmulatorTests.cs b/tests/Healthie.Tests.Unit/CosmosDbRealEmulatorTests.cs new file mode 100644 index 0000000..ef87922 --- /dev/null +++ b/tests/Healthie.Tests.Unit/CosmosDbRealEmulatorTests.cs @@ -0,0 +1,264 @@ +using Healthie.Abstractions.Enums; +using Healthie.Abstractions.Models; +using Healthie.Abstractions.StateProviding; +using Healthie.StateProviding.CosmosDb; +using Microsoft.Azure.Cosmos; + +namespace Healthie.Tests.Unit; + +/// +/// The CosmosDB provider against the real emulator, rather than against a fake that models what the +/// service is documented to do. +/// +/// +/// +/// What the fake could never check: that CosmosDB actually answers 412 to an If-Match +/// on a moved document and 409 to a second create, and that the ETag the read hands back is +/// the one a write will accept. Those are the service's behaviour, not the provider's, and the +/// provider is built entirely on them. +/// +/// +/// The emulator serves a self-signed certificate, so the client is pointed at the callback +/// Testcontainers supplies for it. Skipped rather than failed where there is no container runtime. +/// +/// +public sealed class CosmosDbRealEmulatorTests(CosmosDbFixture fixture) + : IAsyncLifetime, IClassFixture +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private CosmosClient? _client; + private Container? _container_; + private string? _unavailable; + + public async ValueTask InitializeAsync() + { + if (fixture.Unavailable is not null) + { + _unavailable = fixture.Unavailable; + return; + } + + try + { + _client = new CosmosClient( + fixture.ConnectionString, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(fixture.HttpMessageHandler), + }); + + var database = await _client.CreateDatabaseIfNotExistsAsync("healthie", cancellationToken: Ct); + + // The partition key must be /id, which is what the provider's own initializer enforces. + var response = await database.Database.CreateContainerIfNotExistsAsync( + new ContainerProperties("state", "/id"), + cancellationToken: Ct); + + _container_ = response.Container; + } + catch (Exception ex) + { + _unavailable = $"{ex.GetType().Name}: {ex.Message}"; + } + } + + public ValueTask DisposeAsync() + { + _client?.Dispose(); + return ValueTask.CompletedTask; + } + + private bool Unavailable() + { + Assert.SkipWhen(_unavailable is not null, $"The CosmosDB emulator is not reachable. {_unavailable}"); + return false; + } + + private CosmosDbStateProvider Provider() => new(_container_!); + + /// + /// A key unique to the running test, because the emulator is shared across the class. + /// + private static string Key(string name) => $"{TestContext.Current.TestMethod?.MethodName}-{name}"; + + [Fact] + public async Task StateSurvivesARoundTrip() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("round-trip"), new PulseCheckerState(PulseInterval.Every30Seconds), Ct); + + Assert.Equal( + PulseInterval.Every30Seconds, + (await provider.GetStateAsync(Key("round-trip"), Ct))!.Interval); + } + + [Fact] + public async Task NothingStored_ReadsAsNull() + { + if (Unavailable()) + { + return; + } + + Assert.Null(await Provider().GetStateAsync(Key("never-written"), Ct)); + } + + /// The ETag a read hands back has to be one a write will accept. + [Fact] + public async Task AWriteFromACurrentRead_Lands() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("current"), new PulseCheckerState(), Ct); + + var entry = await provider.GetStateEntryAsync(Key("current"), Ct); + Assert.True(entry!.IsVersioned); + + entry.Value.IsPinned = true; + + Assert.True(await provider.TrySetStateAsync(Key("current"), entry.Value, entry.Version, Ct)); + Assert.True((await provider.GetStateAsync(Key("current"), Ct))!.IsPinned); + } + + /// + /// The 412 the whole feature rests on: an If-Match against a document that has moved on + /// must be refused, and refused as a result rather than as an exception escaping the provider. + /// + [Fact] + public async Task AWriteFromAStaleRead_IsRefused() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("stale"), new PulseCheckerState(PulseInterval.EverySecond), Ct); + + var stale = await provider.GetStateEntryAsync(Key("stale"), Ct); + + await provider.SetStateAsync(Key("stale"), new PulseCheckerState(PulseInterval.Every5Minutes), Ct); + + Assert.False(await provider.TrySetStateAsync(Key("stale"), stale!.Value, stale.Version, Ct)); + Assert.Equal( + PulseInterval.Every5Minutes, + (await provider.GetStateAsync(Key("stale"), Ct))!.Interval); + } + + /// The 409: a second create for the same id is refused, not an overwrite. + [Fact] + public async Task ACreateThatLosesToAnotherCreate_IsRefused() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + + Assert.True(await provider.TrySetStateAsync( + Key("created"), new PulseCheckerState { Group = "first" }, IStateProvider.AbsentVersion, Ct)); + + Assert.False(await provider.TrySetStateAsync( + Key("created"), new PulseCheckerState { Group = "second" }, IStateProvider.AbsentVersion, Ct)); + + Assert.Equal("first", (await provider.GetStateAsync(Key("created"), Ct))!.Group); + } + + [Fact] + public async Task EveryWrite_MovesTheETagOn() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("moving"), new PulseCheckerState(), Ct); + var first = (await provider.GetStateEntryAsync(Key("moving"), Ct))!.Version; + + await provider.SetStateAsync(Key("moving"), new PulseCheckerState(PulseInterval.Every2Seconds), Ct); + var second = (await provider.GetStateEntryAsync(Key("moving"), Ct))!.Version; + + Assert.NotEqual(first, second); + } + + /// Many writers from one read: the service must let exactly one through. + [Fact] + public async Task WhenManyWritersRaceFromOneRead_ExactlyOneWins() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("contended"), new PulseCheckerState(), Ct); + + var entry = await provider.GetStateEntryAsync(Key("contended"), Ct); + + const int Writers = 8; + using var readyToRace = new Barrier(Writers); + + var attempts = await Task.WhenAll(Enumerable.Range(0, Writers).Select(i => Task.Run( + async () => + { + readyToRace.SignalAndWait(Ct); + return await provider.TrySetStateAsync( + Key("contended"), + new PulseCheckerState { Group = $"writer-{i}" }, + entry!.Version, + Ct); + }, + Ct))); + + Assert.Equal(1, attempts.Count(won => won)); + } + + [Fact] + public async Task DeleteStateAsync_SaysWhetherThereWasAnythingToRemove() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("removable"), new PulseCheckerState(), Ct); + + Assert.True(await provider.DeleteStateAsync(Key("removable"), Ct)); + Assert.False(await provider.DeleteStateAsync(Key("removable"), Ct)); + } + + /// + /// Reading a state as a type it was not written as must throw rather than hand back something + /// that is not what was stored. + /// + [Fact] + public async Task ReadingAStateAsTheWrongType_Throws() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("typed"), new PulseCheckerState(), Ct); + + var ex = await Assert.ThrowsAsync( + () => provider.GetStateAsync(Key("typed"), Ct)); + + Assert.Contains(Key("typed"), ex.Message, StringComparison.Ordinal); + } +} diff --git a/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs b/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs new file mode 100644 index 0000000..83a1d94 --- /dev/null +++ b/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs @@ -0,0 +1,124 @@ +using Bunit; +using Healthie.Abstractions.Models; +using Healthie.Dashboard; +using Healthie.Dashboard.Components; +using Healthie.Dashboard.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Healthie.Tests.Unit; + +/// +/// The dashboard component's own lifecycle: that it subscribes when it is built and unsubscribes +/// when it is torn down. +/// +/// +/// The service-level unsubscribe was covered; the component calling it was not, and that is the half +/// that leaks. A host routing to the dashboard inside its own layout builds a new component every +/// time the user navigates back to it, on the same circuit, so a component that never unsubscribes +/// leaves a handler behind on every visit. +/// +public sealed class DashboardComponentLifecycleTests : IDisposable +{ + /// Records what the component asked of the service, and refuses everything else. + private sealed class RecordingDashboardService : StubDashboardService + { + public List> Subscribed { get; } = []; + + public List> Unsubscribed { get; } = []; + + public override Task SubscribeToStateChangesAsync( + Func onStateChanged, + CancellationToken cancellationToken = default) + { + Subscribed.Add(onStateChanged); + return Task.CompletedTask; + } + + public override Task UnsubscribeFromStateChangesAsync( + Func onStateChanged, + CancellationToken cancellationToken = default) + { + Unsubscribed.Add(onStateChanged); + return Task.CompletedTask; + } + + public override Task> GetAllStatesAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(new Dictionary(StringComparer.Ordinal)); + + public override Task> GetDisplayNamesAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(new Dictionary(StringComparer.Ordinal)); + } + + private readonly BunitContext _context = new(); + private readonly RecordingDashboardService _service = new(); + + public DashboardComponentLifecycleTests() + { + _context.Services.AddSingleton(_service); + _context.Services.AddSingleton(new HealthieUIOptions()); + _context.Services.AddSingleton(); + _context.Services.AddSingleton(); + + // PersistentComponentState is built by the framework rather than newed up, so it is + // registered the way ASP.NET Core registers it: from the manager that owns it. + _context.Services.AddLogging(); + _context.Services.AddSingleton(); + _context.Services.AddSingleton(sp => + sp.GetRequiredService().State); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public void RenderingTheDashboard_Subscribes() + { + _context.Render(); + + Assert.Single(_service.Subscribed); + Assert.Empty(_service.Unsubscribed); + } + + /// + /// The half that was missing. The handler it hands back has to be the one it subscribed with, or + /// the service cannot remove it and the leak stands. + /// + [Fact] + public async Task DisposingTheDashboard_UnsubscribesTheHandlerItSubscribed() + { + var rendered = _context.Render(); + + Assert.Single(_service.Subscribed); + + await rendered.Instance.DisposeAsync(); + + Assert.Single(_service.Unsubscribed); + Assert.Equal(_service.Subscribed[0], _service.Unsubscribed[0]); + } + + /// + /// Mounting the dashboard twice on one circuit is the case this exists for: navigating away and + /// back builds a second component, and the first must have taken its handler with it. + /// + [Fact] + public async Task NavigatingAwayAndBack_LeavesOneSubscriptionBehindNotTwo() + { + var first = _context.Render(); + await first.Instance.DisposeAsync(); + + var second = _context.Render(); + + Assert.Equal(2, _service.Subscribed.Count); + Assert.Single(_service.Unsubscribed); + + // One subscribed and not yet unsubscribed: the component now on screen. + var outstanding = _service.Subscribed.Count - _service.Unsubscribed.Count; + Assert.Equal(1, outstanding); + + await second.Instance.DisposeAsync(); + Assert.Equal(_service.Subscribed.Count, _service.Unsubscribed.Count); + } +} diff --git a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj index 4d9c72d..49b2c01 100644 --- a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj +++ b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj @@ -16,7 +16,11 @@ + + + + @@ -49,6 +53,8 @@ + + diff --git a/tests/Healthie.Tests.Unit/RealDatabaseProviderTests.cs b/tests/Healthie.Tests.Unit/RealDatabaseProviderTests.cs new file mode 100644 index 0000000..9e6e028 --- /dev/null +++ b/tests/Healthie.Tests.Unit/RealDatabaseProviderTests.cs @@ -0,0 +1,292 @@ +using Healthie.Abstractions.Enums; +using Healthie.Abstractions.Models; +using Healthie.Abstractions.StateProviding; +using Healthie.StateProviding.Relational; +using Npgsql; +using System.Data.Common; + +namespace Healthie.Tests.Unit; + +/// +/// The relational provider against the engines it ships for, rather than against SQLite standing in +/// for them. +/// +/// +/// +/// The dialects differ exactly where concurrency lives: PostgreSQL resolves a create with +/// ON CONFLICT DO NOTHING, SQL Server with UPDLOCK, HOLDLOCK on the existence check, +/// and SQLite serialises writers with a file lock that hides the difference between a correct +/// statement and a lucky one. Everything below had only ever been argued for on those two engines. +/// +/// +/// Skipped rather than failed where there is no container runtime, so the suite still passes on a +/// machine without Docker. +/// +/// +public abstract class RealDatabaseProviderTests : IAsyncLifetime +{ + private const string Table = "healthie_pulse_state"; + + protected static CancellationToken Ct => TestContext.Current.CancellationToken; + + private string? _unavailable; + private Func? _connect; + + /// Why the engine is unusable, from the fixture that owns it. + protected abstract string? FixtureUnavailable { get; } + + /// Opens a connection to the engine the fixture started. + protected abstract DbConnection Connect(); + + /// The SQL this engine speaks. + protected abstract RelationalDialect Dialect { get; } + + /// + /// Creates the table. Runs per test, against the container the fixture started once. + /// + public async ValueTask InitializeAsync() + { + if (FixtureUnavailable is not null) + { + _unavailable = FixtureUnavailable; + return; + } + + try + { + _connect = Connect; + await new RelationalStateProviderInitializer(_connect, Dialect, Table).InitializeAsync(Ct); + } + catch (Exception ex) + { + _unavailable = $"{ex.GetType().Name}: {ex.Message}"; + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private bool Unavailable() + { + Assert.SkipWhen(_unavailable is not null, $"The database is not reachable. {_unavailable}"); + return false; + } + + private RelationalStateProvider Provider() => new(_connect!, Dialect, Table); + + /// + /// A key unique to the running test. + /// + /// + /// The container is shared across the class now, so two tests using the same checker name would + /// see each other's writes. Scoping by test name keeps them apart without a container each. + /// + private string Key(string name) => $"{GetType().Name}-{TestContext.Current.TestMethod?.MethodName}-{name}"; + + [Fact] + public async Task StateSurvivesARoundTrip() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("round-trip"), new PulseCheckerState(PulseInterval.Every30Seconds), Ct); + + Assert.Equal( + PulseInterval.Every30Seconds, + (await provider.GetStateAsync(Key("round-trip"), Ct))!.Interval); + } + + /// + /// The upsert has to insert and then replace, on an engine where two writers can arrive at once. + /// + [Fact] + public async Task WritingTheSameCheckerTwice_Replaces() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("twice"), new PulseCheckerState(PulseInterval.EverySecond), Ct); + await provider.SetStateAsync(Key("twice"), new PulseCheckerState(PulseInterval.Every5Minutes), Ct); + + Assert.Equal( + PulseInterval.Every5Minutes, + (await provider.GetStateAsync(Key("twice"), Ct))!.Interval); + } + + [Fact] + public async Task AWriteFromAStaleRead_IsRefused() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("stale"), new PulseCheckerState(PulseInterval.EverySecond), Ct); + + var stale = await provider.GetStateEntryAsync(Key("stale"), Ct); + await provider.SetStateAsync(Key("stale"), new PulseCheckerState(PulseInterval.Every5Minutes), Ct); + + Assert.False(await provider.TrySetStateAsync(Key("stale"), stale!.Value, stale.Version, Ct)); + Assert.Equal( + PulseInterval.Every5Minutes, + (await provider.GetStateAsync(Key("stale"), Ct))!.Interval); + } + + /// + /// The statement this exists for: ON CONFLICT DO NOTHING on PostgreSQL, and an existence + /// check under UPDLOCK, HOLDLOCK on SQL Server. Both are meant to resolve the race in one + /// step; the portable form they replaced could let two writers both insert and hand one a + /// primary key violation instead of a refusal. + /// + [Fact] + public async Task WhenManyWritersRaceToCreate_ExactlyOneWinsAndNoneThrow() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + + // Repeated over fresh keys, because one round is not enough to land in the window. The + // portable check-then-insert this replaced survives a single round of eight comfortably -- + // measured, not assumed -- and only shows itself over many. + const int Rounds = 40; + const int Writers = 8; + + for (var round = 0; round < Rounds; round++) + { + var name = Key($"contended-create-{round}"); + using var readyToRace = new Barrier(Writers); + + var attempts = await Task.WhenAll(Enumerable.Range(0, Writers).Select(i => Task.Run( + async () => + { + readyToRace.SignalAndWait(Ct); + return await provider.TrySetStateAsync( + name, + new PulseCheckerState { Group = $"writer-{i}" }, + IStateProvider.AbsentVersion, + Ct); + }, + Ct))); + + // A lost create must be reported as a refusal. The racy form hands the loser a primary + // key violation instead, which surfaces here as the await throwing. + Assert.Equal(1, attempts.Count(won => won)); + Assert.NotNull(await provider.GetStateAsync(name, Ct)); + } + } + + /// The same race on the conditional update, which is the ordinary path once state exists. + [Fact] + public async Task WhenManyWritersRaceFromOneRead_ExactlyOneWins() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("contended-update"), new PulseCheckerState(), Ct); + + var entry = await provider.GetStateEntryAsync(Key("contended-update"), Ct); + + const int Writers = 8; + using var readyToRace = new Barrier(Writers); + + var attempts = await Task.WhenAll(Enumerable.Range(0, Writers).Select(i => Task.Run( + async () => + { + readyToRace.SignalAndWait(Ct); + return await provider.TrySetStateAsync( + Key("contended-update"), + new PulseCheckerState { Group = $"writer-{i}" }, + entry!.Version, + Ct); + }, + Ct))); + + Assert.Equal(1, attempts.Count(won => won)); + } + + [Fact] + public async Task DeleteStateAsync_SaysWhetherThereWasAnythingToRemove() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("removable"), new PulseCheckerState(), Ct); + + Assert.True(await provider.DeleteStateAsync(Key("removable"), Ct)); + Assert.False(await provider.DeleteStateAsync(Key("removable"), Ct)); + } + + [Fact] + public async Task GetStatesAsync_ReadsManyInOneQuery() + { + if (Unavailable()) + { + return; + } + + var provider = Provider(); + await provider.SetStateAsync(Key("bulk-a"), new PulseCheckerState(PulseInterval.EverySecond), Ct); + await provider.SetStateAsync(Key("bulk-b"), new PulseCheckerState(PulseInterval.Every5Minutes), Ct); + + var states = await provider.GetStatesAsync([Key("bulk-a"), Key("bulk-b"), Key("bulk-absent")], Ct); + + Assert.Equal(2, states.Count); + Assert.Equal(PulseInterval.EverySecond, states[Key("bulk-a")].Interval); + Assert.Equal(PulseInterval.Every5Minutes, states[Key("bulk-b")].Interval); + } + + /// Running the initializer again must not disturb a table that is already correct. + [Fact] + public async Task RunningTheInitializerTwice_IsHarmless() + { + if (Unavailable()) + { + return; + } + + await new RelationalStateProviderInitializer(_connect!, Dialect, Table).InitializeAsync(Ct); + + var provider = Provider(); + await provider.SetStateAsync(Key("after-reinit"), new PulseCheckerState(), Ct); + + Assert.True((await provider.GetStateEntryAsync(Key("after-reinit"), Ct))!.IsVersioned); + } +} + +/// The provider against a real PostgreSQL. +public sealed class PostgresStateProviderTests(PostgresFixture fixture) + : RealDatabaseProviderTests, IClassFixture +{ + protected override RelationalDialect Dialect => RelationalDialect.PostgreSql; + + protected override string? FixtureUnavailable => fixture.Unavailable; + + protected override DbConnection Connect() => new NpgsqlConnection(fixture.ConnectionString); +} + +/// The provider against a real SQL Server. +public sealed class SqlServerStateProviderTests(SqlServerFixture fixture) + : RealDatabaseProviderTests, IClassFixture +{ + protected override RelationalDialect Dialect => RelationalDialect.SqlServer; + + protected override string? FixtureUnavailable => fixture.Unavailable; + + protected override DbConnection Connect() => + new Microsoft.Data.SqlClient.SqlConnection(fixture.ConnectionString); +} diff --git a/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs b/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs index 13a7258..b0a4819 100644 --- a/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs +++ b/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs @@ -5,7 +5,6 @@ using Healthie.StateProviding.Redis; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; -using Testcontainers.Redis; namespace Healthie.Tests.Unit; @@ -18,24 +17,32 @@ namespace Healthie.Tests.Unit; /// Skipped rather than failed when there is no container runtime, so the suite still passes on a /// machine without Docker -- the same bargain the CosmosDB combinations strike in the E2E project. /// -public sealed class RedisStateProviderTests : IAsyncLifetime +public sealed class RedisStateProviderTests(RedisFixture fixture) + : IAsyncLifetime, IClassFixture { private static CancellationToken Ct => TestContext.Current.CancellationToken; - private RedisContainer? _redis; private IConnectionMultiplexer? _connection; private string? _unavailable; - private RedisStateProvider Provider(string prefix = "test:") => - new(_connection!, prefix); + /// + /// A provider under a prefix unique to the running test, because the server is shared across + /// the class now. + /// + private RedisStateProvider Provider(string? prefix = null) => + new(_connection!, prefix ?? $"{TestContext.Current.TestMethod?.MethodName}:"); public async ValueTask InitializeAsync() { + if (fixture.Unavailable is not null) + { + _unavailable = fixture.Unavailable; + return; + } + try { - _redis = new RedisBuilder("redis:7-alpine").Build(); - await _redis.StartAsync(Ct); - _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString()); + _connection = await ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString); } catch (Exception ex) { @@ -50,11 +57,6 @@ public async ValueTask DisposeAsync() { await _connection.DisposeAsync(); } - - if (_redis is not null) - { - await _redis.DisposeAsync(); - } } private bool Unavailable() @@ -78,7 +80,7 @@ public async Task AddHealthieRedis_WithAConfigurationString_ResolvesAWorkingProv var services = new ServiceCollection(); services.AddHealthie(typeof(RedisStateProviderTests).Assembly); - services.AddHealthieRedis(_redis!.GetConnectionString()); + services.AddHealthieRedis(fixture.ConnectionString); await using var host = services.BuildServiceProvider(); diff --git a/tests/Healthie.Tests.Unit/StubDashboardService.cs b/tests/Healthie.Tests.Unit/StubDashboardService.cs new file mode 100644 index 0000000..cce2521 --- /dev/null +++ b/tests/Healthie.Tests.Unit/StubDashboardService.cs @@ -0,0 +1,55 @@ +using Healthie.Abstractions.Enums; +using Healthie.Abstractions.Models; +using Healthie.Dashboard.Services; + +namespace Healthie.Tests.Unit; + +/// +/// An whose every member refuses, so a test double can +/// override the few it actually needs. +/// +/// +/// The same bargain as : the interface has sixteen members and a test of +/// the component's lifecycle touches three. Refusing rather than returning a default is deliberate +/// -- a test that reaches an unstubbed member has left the path it meant to exercise and should say +/// so rather than quietly carry on. +/// +internal abstract class StubDashboardService : IHealthieDashboardService +{ + private static NotSupportedException NotStubbed(string member) => + new($"{member} is not stubbed. Override it if the test under way is meant to reach it."); + + public virtual Task SubscribeToStateChangesAsync(Func onStateChanged, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SubscribeToStateChangesAsync)); + + public virtual Task UnsubscribeFromStateChangesAsync(Func onStateChanged, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(UnsubscribeFromStateChangesAsync)); + + public virtual Task> GetAllStatesAsync(CancellationToken cancellationToken = default) => throw NotStubbed(nameof(GetAllStatesAsync)); + + public virtual Task> GetDisplayNamesAsync(CancellationToken cancellationToken = default) => throw NotStubbed(nameof(GetDisplayNamesAsync)); + + public virtual Task TriggerAllAsync(CancellationToken cancellationToken = default) => throw NotStubbed(nameof(TriggerAllAsync)); + + public virtual Task SetIntervalAsync(string name, PulseInterval interval, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SetIntervalAsync)); + + public virtual Task SetThresholdAsync(string name, uint threshold, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SetThresholdAsync)); + + public virtual Task SetTagsAsync(string name, IReadOnlyList tags, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SetTagsAsync)); + + public virtual Task SetPinnedAsync(string name, bool pinned, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SetPinnedAsync)); + + public virtual Task SetGroupAsync(string name, string? group, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(SetGroupAsync)); + + public virtual Task StartAsync(string name, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(StartAsync)); + + public virtual Task StopAsync(string name, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(StopAsync)); + + public virtual Task TriggerAsync(string name, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(TriggerAsync)); + + public virtual Task ResetAsync(string name, CancellationToken cancellationToken = default) => throw NotStubbed(nameof(ResetAsync)); + + public virtual Task StartAllAsync(CancellationToken cancellationToken = default) => throw NotStubbed(nameof(StartAllAsync)); + + public virtual Task StopAllAsync(CancellationToken cancellationToken = default) => throw NotStubbed(nameof(StopAllAsync)); + + public virtual ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/Healthie.Tests.Unit/packages.lock.json b/tests/Healthie.Tests.Unit/packages.lock.json index 8320d53..a353a56 100644 --- a/tests/Healthie.Tests.Unit/packages.lock.json +++ b/tests/Healthie.Tests.Unit/packages.lock.json @@ -2,6 +2,19 @@ "version": 2, "dependencies": { "net10.0": { + "bunit": { + "type": "Direct", + "requested": "[2.8.6, )", + "resolved": "2.8.6", + "contentHash": "kBGHiCrn0LvUGRiDaqLxBewZXAOIIqYlJTcfh/+yaMc5XOzWm2eeuDl2+3ksK3AuLSSnVitmCLu8n1RV9CXpSQ==", + "dependencies": { + "AngleSharp": "1.5.2", + "AngleSharp.Css": "1.0.0-beta.224", + "AngleSharp.Diffing": "1.1.1", + "Microsoft.AspNetCore.Components.WebAssembly": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.10" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.8.1, )", @@ -18,6 +31,33 @@ "resolved": "13.0.4", "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" }, + "Testcontainers.CosmosDb": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "JEt91rif3KbDRXJm9FEmxPpXoOZN6wZQy3KMrkmpDhINDN4DI9K48pI19c6zNK99k2jOD0h1IldoXbQ+UQbOoA==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, + "Testcontainers.MsSql": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "TAUNJBgO5WjArP7VW2EpkRm9ZQYlBEN65r2TQF80fJJAsQb9o/thrVmCwuPfZ1/EtABJS5LbgUclefPMkLWGEA==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, "Testcontainers.Redis": { "type": "Direct", "requested": "[4.13.0, )", @@ -42,6 +82,28 @@ "xunit.v3.mtp-v1": "[3.2.2]" } }, + "AngleSharp": { + "type": "Transitive", + "resolved": "1.5.2", + "contentHash": "LVZ7rrr6GHbhERhTgVDarVygl1e6KwE96pronA90LBOMacDhxR4lB+xYLjn/vlnUu+xbOB2RM9fuCPEx5bdyWA==" + }, + "AngleSharp.Css": { + "type": "Transitive", + "resolved": "1.0.0-beta.224", + "contentHash": "BF/hPs8sRjhR+rYkBom7WaMLN0w36Qvnu+WvoHK/gL67MrQOONkkivBTGilvta4sNix5Y8T9XVlZsRI8IU2/vA==", + "dependencies": { + "AngleSharp": "[1.5.0, 2.0.0)" + } + }, + "AngleSharp.Diffing": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "Uzz5895TpRyMA4t8GExmq4TxXqi3+bZTgb3kJgH+tdwrSuo9CO6Dlmv+Oqyhb2pNRfbnReLUzhlHAuT7c+3jSg==", + "dependencies": { + "AngleSharp": "1.1.2", + "AngleSharp.Css": "1.0.0-beta.144" + } + }, "Azure.Core": { "type": "Transitive", "resolved": "1.44.1", @@ -124,11 +186,29 @@ "resolved": "2.23.0", "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" }, + "Microsoft.AspNetCore.Components.WebAssembly": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "0Zib7U6HmGw4Noj/+yJz1YuEJwo3XOV0iLezXDLFsrHTaC8L+KUOR/1+1tX7AAeipDG6jP3gPZOwpirk6G3LYQ==", + "dependencies": { + "Microsoft.JSInterop.WebAssembly": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "D2GIsqVJ2+tVDViJmbEF4tc+gxhdAl5xkBAMZHWCJV7Pt+oBkU2omBSRbgVTq3N2OgreVNSp7w+FrZNgu97d5A==" + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w==" + }, "Microsoft.Bcl.HashCode": { "type": "Transitive", "resolved": "1.1.0", @@ -139,6 +219,24 @@ "resolved": "18.8.1", "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "Zx7z61fG2Nc6LdDn4jA7b5Aj1ABrljkOPRE31RvVUdjF6IgbG60leDUhMCafsnWFgaheiK3iqpLe+kbf5Z5L8Q==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "iqgYBbGSVy/DIYWjzmQOa964Kn4rs4wW5vg2IamqVK0fQqfTiILVR5hMK27XWqoyONtAZs+VxH354cPJBtSnbg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.10", @@ -147,6 +245,62 @@ "SQLitePCLRaw.core": "2.1.11" } }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.JSInterop.WebAssembly": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "FjPAGQWbCP85FWY2Wl9opHSsY+u9nZb8Ui6eTVHEMY/RJateOhW78jEtC4QOQ/6slkS1MKyIAF6ZHIrUR1fX/w==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "1.9.1", @@ -195,11 +349,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "Microsoft.Win32.SystemEvents": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" - }, "ModelContextProtocol.Core": { "type": "Transitive", "resolved": "2.0.0", @@ -268,19 +417,19 @@ }, "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "resolved": "9.0.13", + "contentHash": "GbBrJq9S/gYpHzm7Pxx6Y5tDyfSfyxW6tlP5oiKJV38uf19Wp+GIIAnWfyL1zmNiz1+EjwVapw2WkBFvvqKQzg==", "dependencies": { - "System.Security.Cryptography.ProtectedData": "6.0.0", - "System.Security.Permissions": "6.0.0" + "System.Security.Cryptography.ProtectedData": "9.0.13" } }, - "System.Drawing.Common": { + "System.IdentityModel.Tokens.Jwt": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", "dependencies": { - "Microsoft.Win32.SystemEvents": "6.0.0" + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" } }, "System.IO.Hashing": { @@ -293,26 +442,15 @@ "resolved": "6.0.0", "contentHash": "ntFHArH3I4Lpjf5m4DCXQHJuGwWPNVJPaAvM95Jy/u+2Yzt2ryiyIN04LAogkjP9DeRcEOiviAjQotfmPq/FrQ==" }, - "System.Security.Cryptography.ProtectedData": { + "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" - }, - "System.Security.Permissions": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", - "dependencies": { - "System.Windows.Extensions": "6.0.0" - } + "resolved": "9.0.13", + "contentHash": "dxJhkuoaelvWy588wPXjShNks+ZMiSgXnN75/u+DPbER5PqKrLPDftE0BvGM7nDK/scQAVlD+gRXlCAAjWi58Q==" }, - "System.Windows.Extensions": { + "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", - "dependencies": { - "System.Drawing.Common": "6.0.0" - } + "resolved": "9.0.13", + "contentHash": "t8S9IDpjJKsLpLkeBdW8cWtcPyYqrGu93Dej1RO6WwuL/lkFSqWlan3rMJfortqz1mRIh+sys2AFsSA6jWJ3Jg==" }, "Testcontainers": { "type": "Transitive", @@ -471,6 +609,13 @@ "ModelContextProtocol.AspNetCore": "[2.0.0, )" } }, + "Healthie.NET.Postgres": { + "type": "Project", + "dependencies": { + "Healthie.NET.Relational": "[1.0.0, )", + "Npgsql": "[10.0.3, )" + } + }, "Healthie.NET.Quartz": { "type": "Project", "dependencies": { @@ -500,6 +645,13 @@ "SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )" } }, + "Healthie.NET.SqlServer": { + "type": "Project", + "dependencies": { + "Healthie.NET.Relational": "[1.0.0, )", + "Microsoft.Data.SqlClient": "[7.0.2, )" + } + }, "Healthie.NET.Temporal": { "type": "Project", "dependencies": { @@ -546,6 +698,23 @@ "System.Configuration.ConfigurationManager": "6.0.0" } }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[7.0.2, )", + "resolved": "7.0.2", + "contentHash": "zwv76lANFQQI6Gmp6ntkzMWIWVqm8Wf4Mz00AeGCk1n8HCi5afi6bNynSe18uI0xeL0n6J+Myjk9AiIsL5oSqw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "9.0.13", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.SNI.runtime": "[6.0.2, 7.0.0)", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "[1.0.0, 2.0.0)", + "System.Configuration.ConfigurationManager": "9.0.13", + "System.Security.Cryptography.Pkcs": "9.0.13" + } + }, "Microsoft.Data.Sqlite": { "type": "CentralTransitive", "requested": "[10.0.10, )", @@ -581,6 +750,12 @@ "ModelContextProtocol": "[2.0.0]" } }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, "Quartz.Extensions.DependencyInjection": { "type": "CentralTransitive", "requested": "[3.19.1, )", @@ -627,6 +802,19 @@ } }, "net8.0": { + "bunit": { + "type": "Direct", + "requested": "[2.8.6, )", + "resolved": "2.8.6", + "contentHash": "kBGHiCrn0LvUGRiDaqLxBewZXAOIIqYlJTcfh/+yaMc5XOzWm2eeuDl2+3ksK3AuLSSnVitmCLu8n1RV9CXpSQ==", + "dependencies": { + "AngleSharp": "1.5.2", + "AngleSharp.Css": "1.0.0-beta.224", + "AngleSharp.Diffing": "1.1.1", + "Microsoft.AspNetCore.Components.WebAssembly": "8.0.29", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "8.0.29" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.8.1, )", @@ -643,6 +831,33 @@ "resolved": "13.0.4", "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" }, + "Testcontainers.CosmosDb": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "JEt91rif3KbDRXJm9FEmxPpXoOZN6wZQy3KMrkmpDhINDN4DI9K48pI19c6zNK99k2jOD0h1IldoXbQ+UQbOoA==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, + "Testcontainers.MsSql": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "TAUNJBgO5WjArP7VW2EpkRm9ZQYlBEN65r2TQF80fJJAsQb9o/thrVmCwuPfZ1/EtABJS5LbgUclefPMkLWGEA==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, "Testcontainers.Redis": { "type": "Direct", "requested": "[4.13.0, )", @@ -667,6 +882,28 @@ "xunit.v3.mtp-v1": "[3.2.2]" } }, + "AngleSharp": { + "type": "Transitive", + "resolved": "1.5.2", + "contentHash": "LVZ7rrr6GHbhERhTgVDarVygl1e6KwE96pronA90LBOMacDhxR4lB+xYLjn/vlnUu+xbOB2RM9fuCPEx5bdyWA==" + }, + "AngleSharp.Css": { + "type": "Transitive", + "resolved": "1.0.0-beta.224", + "contentHash": "BF/hPs8sRjhR+rYkBom7WaMLN0w36Qvnu+WvoHK/gL67MrQOONkkivBTGilvta4sNix5Y8T9XVlZsRI8IU2/vA==", + "dependencies": { + "AngleSharp": "[1.5.0, 2.0.0)" + } + }, + "AngleSharp.Diffing": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "Uzz5895TpRyMA4t8GExmq4TxXqi3+bZTgb3kJgH+tdwrSuo9CO6Dlmv+Oqyhb2pNRfbnReLUzhlHAuT7c+3jSg==", + "dependencies": { + "AngleSharp": "1.1.2", + "AngleSharp.Css": "1.0.0-beta.144" + } + }, "Azure.Core": { "type": "Transitive", "resolved": "1.44.1", @@ -749,11 +986,29 @@ "resolved": "2.23.0", "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" }, + "Microsoft.AspNetCore.Components.WebAssembly": { + "type": "Transitive", + "resolved": "8.0.29", + "contentHash": "wAPYLWm1Y2ikdUY0OgsWOJ0A1iQ0GJIvwsgbQKXTBUP/9r5rK3DvmJnZy9a0yEMslJPxvYesACNYDmLz2sTcxQ==", + "dependencies": { + "Microsoft.JSInterop.WebAssembly": "8.0.29" + } + }, + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { + "type": "Transitive", + "resolved": "8.0.29", + "contentHash": "rs3U4TsemB1lqnpBVtLlQbQM3ewtAImIy3OjlhM7YjxJb/1AEXmIno1Wm8S3HPScYmLZtwLrTG5//uNlYzlRag==" + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "Y3t/c7C5XHJGFDnohjf1/9SYF3ZOfEU1fkNQuKg/dGf9hN18yrQj2owHITGfNS3+lKJdW6J4vY98jYu57jCO8A==" + }, "Microsoft.Bcl.HashCode": { "type": "Transitive", "resolved": "1.1.0", @@ -764,6 +1019,24 @@ "resolved": "18.8.1", "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "Zx7z61fG2Nc6LdDn4jA7b5Aj1ABrljkOPRE31RvVUdjF6IgbG60leDUhMCafsnWFgaheiK3iqpLe+kbf5Z5L8Q==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "iqgYBbGSVy/DIYWjzmQOa964Kn4rs4wW5vg2IamqVK0fQqfTiILVR5hMK27XWqoyONtAZs+VxH354cPJBtSnbg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.10", @@ -879,6 +1152,62 @@ "resolved": "10.0.10", "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.JSInterop.WebAssembly": { + "type": "Transitive", + "resolved": "8.0.29", + "contentHash": "aPT2wbEYTKwFYwSGGNEoX0af5jrAPIG8zzMuZ02Hfh4+6+0lFAdRcZ3GEHljoVbE491WSLgoeyMQTcUKx3zYTQ==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "1.9.1", @@ -927,11 +1256,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "Microsoft.Win32.SystemEvents": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" - }, "ModelContextProtocol.Core": { "type": "Transitive", "resolved": "2.0.0", @@ -1003,11 +1327,10 @@ }, "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", "dependencies": { - "System.Security.Cryptography.ProtectedData": "6.0.0", - "System.Security.Permissions": "6.0.0" + "System.Security.Cryptography.ProtectedData": "8.0.0" } }, "System.Diagnostics.DiagnosticSource": { @@ -1015,12 +1338,13 @@ "resolved": "10.0.10", "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" }, - "System.Drawing.Common": { + "System.IdentityModel.Tokens.Jwt": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", "dependencies": { - "Microsoft.Win32.SystemEvents": "6.0.0" + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" } }, "System.IO.Hashing": { @@ -1043,18 +1367,15 @@ "resolved": "10.0.10", "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw==" }, - "System.Security.Cryptography.ProtectedData": { + "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" }, - "System.Security.Permissions": { + "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", - "dependencies": { - "System.Windows.Extensions": "6.0.0" - } + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" }, "System.Text.Encodings.Web": { "type": "Transitive", @@ -1070,14 +1391,6 @@ "System.Text.Encodings.Web": "10.0.10" } }, - "System.Windows.Extensions": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", - "dependencies": { - "System.Drawing.Common": "6.0.0" - } - }, "Testcontainers": { "type": "Transitive", "resolved": "4.13.0", @@ -1237,6 +1550,13 @@ "ModelContextProtocol.AspNetCore": "[2.0.0, )" } }, + "Healthie.NET.Postgres": { + "type": "Project", + "dependencies": { + "Healthie.NET.Relational": "[1.0.0, )", + "Npgsql": "[10.0.3, )" + } + }, "Healthie.NET.Quartz": { "type": "Project", "dependencies": { @@ -1266,6 +1586,13 @@ "SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )" } }, + "Healthie.NET.SqlServer": { + "type": "Project", + "dependencies": { + "Healthie.NET.Relational": "[1.0.0, )", + "Microsoft.Data.SqlClient": "[7.0.2, )" + } + }, "Healthie.NET.Temporal": { "type": "Project", "dependencies": { @@ -1312,6 +1639,23 @@ "System.Configuration.ConfigurationManager": "6.0.0" } }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[7.0.2, )", + "resolved": "7.0.2", + "contentHash": "zwv76lANFQQI6Gmp6ntkzMWIWVqm8Wf4Mz00AeGCk1n8HCi5afi6bNynSe18uI0xeL0n6J+Myjk9AiIsL5oSqw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "8.0.0", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.SNI.runtime": "[6.0.2, 7.0.0)", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "[1.0.0, 2.0.0)", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, "Microsoft.Data.Sqlite": { "type": "CentralTransitive", "requested": "[10.0.10, )", @@ -1394,6 +1738,15 @@ "ModelContextProtocol": "[2.0.0]" } }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "9.0.11" + } + }, "Quartz.Extensions.DependencyInjection": { "type": "CentralTransitive", "requested": "[3.19.1, )",