From 40454ca4d7f0665b0636f42dc09e9206a1803729 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 07:34:13 +0300 Subject: [PATCH] Run the providers against the real thing Four things were shipped on argument rather than evidence, and this is the evidence. PostgreSQL and SQL Server had never executed their own SQL. Only SQLite ran, and SQLite serialises writers with a file lock, which hides the difference between a correct statement and a lucky one -- the exact difference the two dialects exist to express. Both now run the whole suite against a real engine in a container, and both prove their own concurrency claim: remove ON CONFLICT DO NOTHING from PostgreSQL and the create race fails with 23505, duplicate key; remove UPDLOCK, HOLDLOCK from SQL Server and it fails with a primary key violation. Restore either and forty rounds of eight writers pass. That race test needed strengthening first. One round of eight writers passed against the racy statement it was meant to catch -- measured, not assumed -- so it runs forty rounds over fresh keys now, and the racy form fails in the first few seconds. CosmosDB had a hand-written fake that modelled what the service is documented to do. It now runs against the emulator, which is the only thing that can say whether CosmosDB really answers 412 to a stale If-Match and 409 to a second create, and whether the ETag a read hands back is one a write accepts. The provider is built entirely on those three answers. The dashboard component's unsubscribe was the half that leaks and the half without a test. bunit renders it, disposes it, and checks the handler it gives back is the one it subscribed with; mounting twice and disposing once leaves exactly one subscription outstanding. Removing the unsubscribe fails two of the three. One design fault found on the way, in tests written earlier today. xUnit builds a fresh instance of a test class per test method, so a container started from IAsyncLifetime on the class starts once per test. The CosmosDB emulator was being started nine times for nine tests, took sixteen and a half minutes, and two of them timed out waiting and skipped. Class fixtures start one container and share it: Cosmos went to nine passing in seconds, SQL Server from seventy seconds to one. Tests scope their keys by test name, because a shared server means shared state. The suite is 501 tests with nothing skipped, where it was 473 with eighteen skipped for want of a container. --- Directory.Packages.props | 4 + .../Healthie.Tests.Unit/ContainerFixtures.cs | 105 ++++ .../CosmosDbRealEmulatorTests.cs | 264 ++++++++++ .../DashboardComponentLifecycleTests.cs | 124 +++++ .../Healthie.Tests.Unit.csproj | 6 + .../RealDatabaseProviderTests.cs | 292 +++++++++++ .../RedisStateProviderTests.cs | 30 +- .../StubDashboardService.cs | 55 ++ tests/Healthie.Tests.Unit/packages.lock.json | 473 +++++++++++++++--- 9 files changed, 1279 insertions(+), 74 deletions(-) create mode 100644 tests/Healthie.Tests.Unit/ContainerFixtures.cs create mode 100644 tests/Healthie.Tests.Unit/CosmosDbRealEmulatorTests.cs create mode 100644 tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs create mode 100644 tests/Healthie.Tests.Unit/RealDatabaseProviderTests.cs create mode 100644 tests/Healthie.Tests.Unit/StubDashboardService.cs 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, )",