Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@
<PackageVersion Include="Microsoft.Playwright" Version="1.61.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="bunit" Version="2.8.6" />
<PackageVersion Include="Testcontainers.Redis" Version="4.13.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.13.0" />
<PackageVersion Include="Testcontainers.CosmosDb" Version="4.13.0" />
</ItemGroup>

</Project>
105 changes: 105 additions & 0 deletions tests/Healthie.Tests.Unit/ContainerFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using DotNet.Testcontainers.Containers;
using Testcontainers.CosmosDb;
using Testcontainers.MsSql;
using Testcontainers.PostgreSql;
using Testcontainers.Redis;

namespace Healthie.Tests.Unit;

/// <summary>
/// Starts one container for a whole test class rather than one per test.
/// </summary>
/// <remarks>
/// <para>
/// xUnit builds a fresh instance of a test class for every test method, so a container started from
/// <see cref="IAsyncLifetime"/> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <typeparam name="TContainer">The container this fixture owns.</typeparam>
public abstract class ContainerFixture<TContainer> : IAsyncLifetime
where TContainer : IContainer
{
/// <summary>The container, once it has started.</summary>
protected TContainer? Container { get; private set; }

/// <summary>Why the container is not usable, or <c>null</c> when it is.</summary>
public string? Unavailable { get; private set; }

/// <summary>Builds the container. Called once.</summary>
protected abstract TContainer Build();

/// <summary>Anything that has to happen after the container is up, such as creating a schema.</summary>
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();
}
}
}

/// <summary>One Redis for the Redis tests.</summary>
public sealed class RedisFixture : ContainerFixture<RedisContainer>
{
protected override RedisContainer Build() => new RedisBuilder("redis:7-alpine").Build();

/// <summary>The connection string, once the container is up.</summary>
public string ConnectionString => Container!.GetConnectionString();
}

/// <summary>One PostgreSQL for the PostgreSQL tests.</summary>
public sealed class PostgresFixture : ContainerFixture<PostgreSqlContainer>
{
protected override PostgreSqlContainer Build() => new PostgreSqlBuilder("postgres:17-alpine").Build();

/// <summary>The connection string, once the container is up.</summary>
public string ConnectionString => Container!.GetConnectionString();
}

/// <summary>One SQL Server for the SQL Server tests.</summary>
public sealed class SqlServerFixture : ContainerFixture<MsSqlContainer>
{
protected override MsSqlContainer Build() =>
new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build();

/// <summary>The connection string, once the container is up.</summary>
public string ConnectionString => Container!.GetConnectionString();
}

/// <summary>
/// One CosmosDB emulator for the CosmosDB tests, which is the one that most needs sharing: it takes
/// minutes to become ready.
/// </summary>
public sealed class CosmosDbFixture : ContainerFixture<CosmosDbContainer>
{
protected override CosmosDbContainer Build() => new CosmosDbBuilder().Build();

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / build

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / build

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / build

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / build

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / End-to-end (browser)

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / End-to-end (browser)

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / End-to-end (browser)

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 98 in tests/Healthie.Tests.Unit/ContainerFixtures.cs

View workflow job for this annotation

GitHub Actions / End-to-end (browser)

'CosmosDbBuilder.CosmosDbBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

/// <summary>The connection string, once the container is up.</summary>
public string ConnectionString => Container!.GetConnectionString();

/// <summary>The handler that accepts the emulator's self-signed certificate.</summary>
public HttpMessageHandler HttpMessageHandler => Container!.HttpMessageHandler;
}
264 changes: 264 additions & 0 deletions tests/Healthie.Tests.Unit/CosmosDbRealEmulatorTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The CosmosDB provider against the real emulator, rather than against a fake that models what the
/// service is documented to do.
/// </summary>
/// <remarks>
/// <para>
/// What the fake could never check: that CosmosDB actually answers <c>412</c> to an <c>If-Match</c>
/// on a moved document and <c>409</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class CosmosDbRealEmulatorTests(CosmosDbFixture fixture)
: IAsyncLifetime, IClassFixture<CosmosDbFixture>
{
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_!);

/// <summary>
/// A key unique to the running test, because the emulator is shared across the class.
/// </summary>
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<PulseCheckerState>(Key("round-trip"), Ct))!.Interval);
}

[Fact]
public async Task NothingStored_ReadsAsNull()
{
if (Unavailable())
{
return;
}

Assert.Null(await Provider().GetStateAsync<PulseCheckerState>(Key("never-written"), Ct));
}

/// <summary>The ETag a read hands back has to be one a write will accept.</summary>
[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<PulseCheckerState>(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<PulseCheckerState>(Key("current"), Ct))!.IsPinned);
}

/// <summary>
/// The 412 the whole feature rests on: an <c>If-Match</c> against a document that has moved on
/// must be refused, and refused as a result rather than as an exception escaping the provider.
/// </summary>
[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<PulseCheckerState>(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<PulseCheckerState>(Key("stale"), Ct))!.Interval);
}

/// <summary>The 409: a second create for the same id is refused, not an overwrite.</summary>
[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<PulseCheckerState>(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<PulseCheckerState>(Key("moving"), Ct))!.Version;

await provider.SetStateAsync(Key("moving"), new PulseCheckerState(PulseInterval.Every2Seconds), Ct);
var second = (await provider.GetStateEntryAsync<PulseCheckerState>(Key("moving"), Ct))!.Version;

Assert.NotEqual(first, second);
}

/// <summary>Many writers from one read: the service must let exactly one through.</summary>
[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<PulseCheckerState>(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));
}

/// <summary>
/// Reading a state as a type it was not written as must throw rather than hand back something
/// that is not what was stored.
/// </summary>
[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<InvalidOperationException>(
() => provider.GetStateAsync<PulseCheckerResult>(Key("typed"), Ct));

Assert.Contains(Key("typed"), ex.Message, StringComparison.Ordinal);
}
}
Loading
Loading