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, )",