diff --git a/Vulthil.SharedKernel.slnx b/Vulthil.SharedKernel.slnx
index 2fd956a9..7f835ea2 100644
--- a/Vulthil.SharedKernel.slnx
+++ b/Vulthil.SharedKernel.slnx
@@ -123,6 +123,10 @@
+
+
+
+
diff --git a/tests/Vulthil.Extensions.Testing.Tests/HttpResponseMessageExtensionsTests.cs b/tests/Vulthil.Extensions.Testing.Tests/HttpResponseMessageExtensionsTests.cs
new file mode 100644
index 00000000..8c3134ac
--- /dev/null
+++ b/tests/Vulthil.Extensions.Testing.Tests/HttpResponseMessageExtensionsTests.cs
@@ -0,0 +1,74 @@
+using System.Net;
+using System.Net.Http.Json;
+using Vulthil.xUnit;
+
+namespace Vulthil.Extensions.Testing.Tests;
+
+public sealed class HttpResponseMessageExtensionsTests : BaseUnitTestCase
+{
+ [Fact]
+ public async Task DeserializesTheResponseBodyOnSuccess()
+ {
+ // Arrange
+ using var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = JsonContent.Create(new TestPayload("Ada")),
+ };
+
+ // Act
+ var payload = await response.GetResponseAsync(CancellationToken);
+
+ // Assert
+ payload.Name.ShouldBe("Ada");
+ }
+
+ [Fact]
+ public async Task NullResponseThrowsArgumentNullException()
+ {
+ // Arrange
+ HttpResponseMessage? response = null;
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => response.GetResponseAsync(CancellationToken));
+
+ // Assert
+ exception.ParamName.ShouldBe("response");
+ }
+
+ [Fact]
+ public async Task NonSuccessStatusCodeThrows()
+ {
+ // Arrange
+ using var response = new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = JsonContent.Create(new TestPayload("Ada")),
+ };
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => response.GetResponseAsync(CancellationToken));
+
+ // Assert
+ exception.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task NullJsonBodyThrowsInvalidOperationException()
+ {
+ // Arrange
+ using var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = JsonContent.Create(null),
+ };
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => response.GetResponseAsync(CancellationToken));
+
+ // Assert
+ exception.Message.ShouldBe("Response content is empty or could not be deserialized.");
+ }
+
+ public sealed record TestPayload(string Name);
+}
diff --git a/tests/Vulthil.Extensions.Testing.Tests/PollingTests.cs b/tests/Vulthil.Extensions.Testing.Tests/PollingTests.cs
new file mode 100644
index 00000000..e7ce86de
--- /dev/null
+++ b/tests/Vulthil.Extensions.Testing.Tests/PollingTests.cs
@@ -0,0 +1,253 @@
+using System.Diagnostics;
+using Vulthil.Results;
+using Vulthil.xUnit;
+
+namespace Vulthil.Extensions.Testing.Tests;
+
+public sealed class PollingWaitAsyncOfTTests : BaseUnitTestCase
+{
+ private static readonly TimeSpan ShortTick = TimeSpan.FromMilliseconds(15);
+
+ [Fact]
+ public async Task SucceedsOnTheFirstAttemptWithoutWaitingForATick()
+ {
+ // Arrange
+ var callCount = 0;
+ Task> Poll(CancellationToken ct)
+ {
+ callCount++;
+ return Task.FromResult(Result.Success(42));
+ }
+ var stopwatch = Stopwatch.StartNew();
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, TimeSpan.FromSeconds(5), CancellationToken);
+ stopwatch.Stop();
+
+ // Assert
+ result.IsSuccess.ShouldBeTrue();
+ result.Value.ShouldBe(42);
+ callCount.ShouldBe(1);
+ stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+
+ [Fact]
+ public async Task SucceedsOnALaterAttemptAfterInitialFailures()
+ {
+ // Arrange
+ var attempt = 0;
+ Task> Poll(CancellationToken ct)
+ {
+ attempt++;
+ return Task.FromResult(attempt < 3
+ ? Result.Failure(Error.Failure($"attempt-{attempt}", "not yet"))
+ : Result.Success("done"));
+ }
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, ShortTick, CancellationToken);
+
+ // Assert
+ result.IsSuccess.ShouldBeTrue();
+ result.Value.ShouldBe("done");
+ attempt.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task TimingOutAggregatesEveryAttemptsErrorsInOrder()
+ {
+ // Arrange
+ var attempt = 0;
+ Task> Poll(CancellationToken ct)
+ {
+ attempt++;
+ return Task.FromResult(Result.Failure(Error.Failure($"error-{attempt}", "still failing")));
+ }
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromMilliseconds(70), Poll, ShortTick, CancellationToken);
+
+ // Assert
+ result.IsSuccess.ShouldBeFalse();
+ result.PollingError.ShouldNotBeNull();
+ result.PollingError.Code.ShouldBe(Polling.Timeout.Code);
+ result.PollingError.Description.ShouldBe(Polling.Timeout.Description);
+ result.PollingError.Errors.Count.ShouldBe(attempt);
+ result.PollingError.Errors.Select(error => error.Code)
+ .ShouldBe(Enumerable.Range(1, attempt).Select(index => $"error-{index}"));
+ }
+
+ [Fact]
+ public async Task TimingOutCompletesNormallyWithoutThrowing()
+ {
+ // Act
+ var result = await Polling.WaitAsync(
+ TimeSpan.FromMilliseconds(40),
+ _ => Task.FromResult(Result.Failure(Error.Failure("nope", "nope"))),
+ ShortTick,
+ CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.ShouldBeFalse();
+ result.PollingError.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task ExternalCancellationThrowsInsteadOfReturningATimeoutResult()
+ {
+ // Arrange
+ using var externalCts = new CancellationTokenSource();
+ var observedToken = default(CancellationToken);
+ Task> Poll(CancellationToken ct)
+ {
+ observedToken = ct;
+ return Task.FromResult(Result.Failure(Error.Failure("still-going", "not yet")));
+ }
+ externalCts.CancelAfter(TimeSpan.FromMilliseconds(30));
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));
+
+ // Assert
+ exception.ShouldNotBeNull();
+ observedToken.IsCancellationRequested.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task NullFuncThrowsArgumentNullException()
+ {
+ // Arrange
+ Func>>? func = null;
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => Polling.WaitAsync(TimeSpan.FromSeconds(1), func!, CancellationToken));
+
+ // Assert
+ exception.ParamName.ShouldBe("func");
+ }
+}
+
+public sealed class PollingWaitAsyncTests : BaseUnitTestCase
+{
+ private static readonly TimeSpan ShortTick = TimeSpan.FromMilliseconds(15);
+
+ [Fact]
+ public async Task SucceedsOnTheFirstAttemptWithoutWaitingForATick()
+ {
+ // Arrange
+ var callCount = 0;
+ Task Poll(CancellationToken ct)
+ {
+ callCount++;
+ return Task.FromResult(Result.Success());
+ }
+ var stopwatch = Stopwatch.StartNew();
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, TimeSpan.FromSeconds(5), CancellationToken);
+ stopwatch.Stop();
+
+ // Assert
+ result.IsSuccess.ShouldBeTrue();
+ callCount.ShouldBe(1);
+ stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+
+ [Fact]
+ public async Task SucceedsOnALaterAttemptAfterInitialFailures()
+ {
+ // Arrange
+ var attempt = 0;
+ Task Poll(CancellationToken ct)
+ {
+ attempt++;
+ return Task.FromResult(attempt < 3
+ ? Result.Failure(Error.Failure($"attempt-{attempt}", "not yet"))
+ : Result.Success());
+ }
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, ShortTick, CancellationToken);
+
+ // Assert
+ result.IsSuccess.ShouldBeTrue();
+ attempt.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task TimingOutAggregatesEveryAttemptsErrorsInOrder()
+ {
+ // Arrange
+ var attempt = 0;
+ Task Poll(CancellationToken ct)
+ {
+ attempt++;
+ return Task.FromResult(Result.Failure(Error.Failure($"error-{attempt}", "still failing")));
+ }
+
+ // Act
+ var result = await Polling.WaitAsync(TimeSpan.FromMilliseconds(70), Poll, ShortTick, CancellationToken);
+
+ // Assert
+ result.IsSuccess.ShouldBeFalse();
+ result.PollingError.ShouldNotBeNull();
+ result.PollingError.Code.ShouldBe(Polling.Timeout.Code);
+ result.PollingError.Description.ShouldBe(Polling.Timeout.Description);
+ result.PollingError.Errors.Count.ShouldBe(attempt);
+ result.PollingError.Errors.Select(error => error.Code)
+ .ShouldBe(Enumerable.Range(1, attempt).Select(index => $"error-{index}"));
+ }
+
+ [Fact]
+ public async Task TimingOutCompletesNormallyWithoutThrowing()
+ {
+ // Act
+ var result = await Polling.WaitAsync(
+ TimeSpan.FromMilliseconds(40),
+ _ => Task.FromResult(Result.Failure(Error.Failure("nope", "nope"))),
+ ShortTick,
+ CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.ShouldBeFalse();
+ result.PollingError.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task ExternalCancellationThrowsInsteadOfReturningATimeoutResult()
+ {
+ // Arrange
+ using var externalCts = new CancellationTokenSource();
+ var observedToken = default(CancellationToken);
+ Task Poll(CancellationToken ct)
+ {
+ observedToken = ct;
+ return Task.FromResult(Result.Failure(Error.Failure("still-going", "not yet")));
+ }
+ externalCts.CancelAfter(TimeSpan.FromMilliseconds(30));
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));
+
+ // Assert
+ exception.ShouldNotBeNull();
+ observedToken.IsCancellationRequested.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task NullFuncThrowsArgumentNullException()
+ {
+ // Arrange
+ Func>? func = null;
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => Polling.WaitAsync(TimeSpan.FromSeconds(1), func!, CancellationToken));
+
+ // Assert
+ exception.ParamName.ShouldBe("func");
+ }
+}
diff --git a/tests/Vulthil.Extensions.Testing.Tests/Vulthil.Extensions.Testing.Tests.csproj b/tests/Vulthil.Extensions.Testing.Tests/Vulthil.Extensions.Testing.Tests.csproj
new file mode 100644
index 00000000..fee651eb
--- /dev/null
+++ b/tests/Vulthil.Extensions.Testing.Tests/Vulthil.Extensions.Testing.Tests.csproj
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/Vulthil.SharedKernel.Tests/Testing/BaseUnitTestCaseDisposalTests.cs b/tests/Vulthil.xUnit.Tests/BaseUnitTestCaseDisposalTests.cs
similarity index 98%
rename from tests/Vulthil.SharedKernel.Tests/Testing/BaseUnitTestCaseDisposalTests.cs
rename to tests/Vulthil.xUnit.Tests/BaseUnitTestCaseDisposalTests.cs
index eb3796d6..b779eca7 100644
--- a/tests/Vulthil.SharedKernel.Tests/Testing/BaseUnitTestCaseDisposalTests.cs
+++ b/tests/Vulthil.xUnit.Tests/BaseUnitTestCaseDisposalTests.cs
@@ -1,6 +1,4 @@
-using Vulthil.xUnit;
-
-namespace Vulthil.SharedKernel.Tests.Testing;
+namespace Vulthil.xUnit.Tests;
public sealed class BaseUnitTestCaseDisposalTests : BaseUnitTestCase
{
diff --git a/tests/Vulthil.xUnit.Tests/BaseUnitTestCaseLifecycleTests.cs b/tests/Vulthil.xUnit.Tests/BaseUnitTestCaseLifecycleTests.cs
new file mode 100644
index 00000000..ec585af4
--- /dev/null
+++ b/tests/Vulthil.xUnit.Tests/BaseUnitTestCaseLifecycleTests.cs
@@ -0,0 +1,175 @@
+namespace Vulthil.xUnit.Tests;
+
+public sealed class TargetCreationTimingTests : BaseUnitTestCase
+{
+ private int _creationCount;
+
+ protected override Marker CreateInstance()
+ {
+ _creationCount++;
+ return base.CreateInstance();
+ }
+
+ [Fact]
+ public void AccessingTargetMultipleTimesCreatesItOnlyOnce()
+ {
+ // Act
+ _ = Target;
+ _ = Target;
+ _ = Target;
+
+ // Assert
+ _creationCount.ShouldBe(1);
+ }
+
+ public sealed class Marker
+ {
+ public Guid Id { get; } = Guid.NewGuid();
+ }
+}
+
+public sealed class CreateInstanceHelperTests : BaseUnitTestCase
+{
+ [Fact]
+ public void EachCallProducesAFreshInstanceUnlikeTheCachedTarget()
+ {
+ // Act
+ var first = CreateInstance();
+ var second = CreateInstance();
+
+ // Assert
+ first.ShouldNotBeSameAs(second);
+ }
+
+ public sealed class Marker
+ {
+ public Guid Id { get; } = Guid.NewGuid();
+ }
+}
+
+public sealed class GetMockConfigurationTests : BaseUnitTestCase
+{
+ [Fact]
+ public void ConfiguringGetMockBeforeFirstTargetAccessIsObservedByTheCreatedTarget()
+ {
+ // Arrange
+ GetMock().Setup(greeter => greeter.Greet()).Returns("configured");
+
+ // Act
+ var greeting = Target.Greet();
+
+ // Assert
+ greeting.ShouldBe("configured");
+ }
+
+ public interface IGreeter
+ {
+ string Greet();
+ }
+
+ public sealed class Consumer(IGreeter greeter)
+ {
+ public string Greet() => greeter.Greet();
+ }
+}
+
+public sealed class UseRegistrationTests : BaseUnitTestCase
+{
+ [Fact]
+ public void UseRegistersTheInstanceOnTheUnderlyingAutoMocker()
+ {
+ // Arrange
+ var service = new FakeService();
+
+ // Act
+ Use(service);
+
+ // Assert
+ AutoMocker.Get().ShouldBeSameAs(service);
+ }
+
+ public interface IFakeService;
+
+ public sealed class FakeService : IFakeService;
+}
+
+public sealed class InitializeHookTests : BaseUnitTestCase
+{
+ private bool _initializeCalled;
+
+ protected override ValueTask Initialize()
+ {
+ _initializeCalled = true;
+ return base.Initialize();
+ }
+
+ [Fact]
+ public async Task InitializeAsyncInvokesTheInitializeHook()
+ {
+ // Act
+ await InitializeAsync();
+
+ // Assert
+ _initializeCalled.ShouldBeTrue();
+ }
+}
+
+public sealed class DisposeHookTests : BaseUnitTestCase
+{
+ private bool _disposeCalled;
+
+ protected override ValueTask Dispose()
+ {
+ _disposeCalled = true;
+ return base.Dispose();
+ }
+
+ [Fact]
+ public async Task DisposeAsyncInvokesTheDisposeHook()
+ {
+ // Act
+ await DisposeAsync();
+
+ // Assert
+ _disposeCalled.ShouldBeTrue();
+ }
+}
+
+public sealed class TargetDisposedBeforeAutoMockerDependenciesTests
+ : BaseUnitTestCase
+{
+ private readonly RecordingDependency _dependency = new();
+
+ public TargetDisposedBeforeAutoMockerDependenciesTests() => Use(_dependency);
+
+ [Fact]
+ public async Task TargetIsDisposedBeforeAutoMockerHeldDependenciesAreSwept()
+ {
+ // Arrange
+ var target = Target;
+
+ // Act
+ await DisposeAsync();
+
+ // Assert
+ target.DependencyWasAlreadyDisposedWhenTargetWasDisposed.ShouldBeFalse();
+ _dependency.DisposeCount.ShouldBe(1);
+ }
+
+ public interface IDependency : IDisposable;
+
+ public sealed class RecordingDependency : IDependency
+ {
+ public int DisposeCount { get; private set; }
+
+ public void Dispose() => DisposeCount++;
+ }
+
+ public sealed class Consumer(IDependency dependency) : IDisposable
+ {
+ public bool DependencyWasAlreadyDisposedWhenTargetWasDisposed { get; private set; }
+
+ public void Dispose() =>
+ DependencyWasAlreadyDisposedWhenTargetWasDisposed = ((RecordingDependency)dependency).DisposeCount > 0;
+ }
+}
diff --git a/tests/Vulthil.xUnit.Tests/Fixtures/ContainerHostTests.cs b/tests/Vulthil.xUnit.Tests/Fixtures/ContainerHostTests.cs
new file mode 100644
index 00000000..82d627c7
--- /dev/null
+++ b/tests/Vulthil.xUnit.Tests/Fixtures/ContainerHostTests.cs
@@ -0,0 +1,167 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Vulthil.xUnit.Fixtures;
+using Xunit.Sdk;
+
+namespace Vulthil.xUnit.Tests.Fixtures;
+
+public sealed class ContainerHostTests : BaseUnitTestCase
+{
+ protected override TestableContainerHost CreateInstance() => new(GetMock().Object);
+
+ [Fact]
+ public async Task InitializeAsyncRunsConfigureContainersExactlyOnceEvenWhenCalledRepeatedly()
+ {
+ // Act
+ await Target.InitializeAsync();
+ await Target.InitializeAsync();
+ await Target.InitializeAsync();
+
+ // Assert
+ Target.ConfigureContainersCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task ContainersReflectsEveryContainerRegisteredDuringConfiguration()
+ {
+ // Arrange
+#pragma warning disable CA2000 // Ownership transfers to the host under test; its own DisposeAsync is what tears these down.
+ var first = new FakeTestContainer();
+ var second = new FakeTestContainer();
+#pragma warning restore CA2000
+ Target.QueueContainer(first);
+ Target.QueueContainer(second);
+
+ // Act
+ await Target.InitializeAsync();
+
+ // Assert
+ Target.Containers.ShouldBe([first, second], ignoreOrder: true);
+ }
+
+ [Fact]
+ public async Task EnsureStartedAsyncInitializesARegisteredContainerExactlyOnceAcrossConcurrentCallers()
+ {
+ // Arrange
+#pragma warning disable CA2000 // Ownership transfers to the host under test; its own DisposeAsync is what tears this down.
+ var container = new FakeTestContainer();
+#pragma warning restore CA2000
+ Target.QueueContainer(container);
+ await Target.InitializeAsync();
+
+ // Act
+ await Task.WhenAll(
+ Target.EnsureStartedAsync(container),
+ Target.EnsureStartedAsync(container),
+ Target.EnsureStartedAsync(container));
+
+ // Assert
+ container.InitializeCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task EnsureStartedAsyncThrowsForAContainerThatWasNeverRegistered()
+ {
+ // Arrange
+#pragma warning disable CA2000 // Never registered, so the host never takes ownership; nothing to dispose or await.
+ var unregistered = new FakeTestContainer();
+#pragma warning restore CA2000
+ await Target.InitializeAsync();
+
+ // Act
+ var exception = await Should.ThrowAsync(
+ () => Target.EnsureStartedAsync(unregistered));
+
+ // Assert
+ exception.Message.ShouldContain(nameof(FakeTestContainer));
+ }
+
+ [Fact]
+ public async Task DisposeAsyncOnlyDisposesContainersThatWereActuallyStarted()
+ {
+ // Arrange
+#pragma warning disable CA2000 // Ownership transfers to the host under test; its own DisposeAsync is what tears these down.
+ var started = new FakeTestContainer();
+ var neverStarted = new FakeTestContainer();
+#pragma warning restore CA2000
+ Target.QueueContainer(started);
+ Target.QueueContainer(neverStarted);
+ await Target.InitializeAsync();
+ await Target.EnsureStartedAsync(started);
+
+ // Act
+ await Target.DisposeAsync();
+
+ // Assert
+ started.DisposeCount.ShouldBe(1);
+ neverStarted.DisposeCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task DisposeAsyncStillDisposesAContainerThatFailedToStart()
+ {
+ // Arrange
+#pragma warning disable CA2000 // Ownership transfers to the host under test; its own DisposeAsync is what tears this down.
+ var failing = new FakeTestContainer(() => throw new InvalidOperationException("boom"));
+#pragma warning restore CA2000
+ Target.QueueContainer(failing);
+ await Target.InitializeAsync();
+
+ // Act
+ await Should.ThrowAsync(() => Target.EnsureStartedAsync(failing));
+ await Target.DisposeAsync();
+
+ // Assert
+ failing.DisposeCount.ShouldBe(1);
+ }
+
+ public sealed class TestableContainerHost(IMessageSink messageSink) : ContainerHost(messageSink)
+ {
+ private readonly List _pendingContainers = [];
+
+ public int ConfigureContainersCallCount { get; private set; }
+
+ public void QueueContainer(ITestContainer container) => _pendingContainers.Add(container);
+
+ protected override Task ConfigureContainers()
+ {
+ ConfigureContainersCallCount++;
+ foreach (var container in _pendingContainers)
+ {
+ AddContainer(container);
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+
+ public sealed class FakeTestContainer(Func? onInitialize = null) : ITestContainer
+ {
+ public int InitializeCount { get; private set; }
+
+ public int DisposeCount { get; private set; }
+
+ public async ValueTask InitializeAsync()
+ {
+ InitializeCount++;
+ if (onInitialize is not null)
+ {
+ await onInitialize();
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ DisposeCount++;
+ return ValueTask.CompletedTask;
+ }
+
+ public void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ }
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ }
+ }
+}
diff --git a/tests/Vulthil.xUnit.Tests/Fixtures/SanitizeDatabaseNameTests.cs b/tests/Vulthil.xUnit.Tests/Fixtures/SanitizeDatabaseNameTests.cs
new file mode 100644
index 00000000..733bfcd4
--- /dev/null
+++ b/tests/Vulthil.xUnit.Tests/Fixtures/SanitizeDatabaseNameTests.cs
@@ -0,0 +1,107 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore;
+using Respawn;
+using Testcontainers.PostgreSql;
+using Vulthil.xUnit.Fixtures;
+using Xunit.Sdk;
+
+namespace Vulthil.xUnit.Tests.Fixtures;
+
+public sealed class SanitizeDatabaseNameTests : BaseUnitTestCase
+{
+ [Theory]
+ [InlineData("myscope", "myscope")]
+ [InlineData("MyScope", "myscope")]
+ [InlineData("my-scope.db", "my_scope_db")]
+ [InlineData("123abc", "db_123abc")]
+ [InlineData("-abc", "db__abc")]
+ [InlineData("a b", "a_b")]
+ public void NormalizesScopeIdsAccordingToEngineNamingRules(string scopeId, string expected)
+ {
+ // Act
+ var sanitized = FakeDatabaseFixture.SanitizeName(scopeId);
+
+ // Assert
+ sanitized.ShouldBe(expected);
+ }
+
+ [Fact]
+ public void ANameOfExactlySixtyThreeCharactersIsReturnedUnchanged()
+ {
+ // Arrange
+ var scopeId = new string('a', 63);
+
+ // Act
+ var sanitized = FakeDatabaseFixture.SanitizeName(scopeId);
+
+ // Assert
+ sanitized.ShouldBe(scopeId);
+ }
+
+ [Fact]
+ public void ANameLongerThanSixtyThreeCharactersIsTruncated()
+ {
+ // Arrange
+ var scopeId = new string('a', 100);
+
+ // Act
+ var sanitized = FakeDatabaseFixture.SanitizeName(scopeId);
+
+ // Assert
+ sanitized.Length.ShouldBe(63);
+ sanitized.ShouldBe(new string('a', 63));
+ }
+
+ [Fact]
+ public void APrefixedNameLongerThanSixtyThreeCharactersIsTruncatedAfterThePrefixIsAdded()
+ {
+ // Arrange
+ var scopeId = "1" + new string('a', 65);
+
+ // Act
+ var sanitized = FakeDatabaseFixture.SanitizeName(scopeId);
+
+ // Assert
+ sanitized.Length.ShouldBe(63);
+ sanitized.ShouldStartWith("db_1");
+ }
+
+ [Fact]
+ public void NullScopeIdThrowsArgumentNullException()
+ {
+ // Act
+ var exception = Should.Throw(() => FakeDatabaseFixture.SanitizeName(null!));
+
+ // Assert
+ exception.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void EmptyScopeIdThrowsArgumentException()
+ {
+ // Act
+ var exception = Should.Throw(() => FakeDatabaseFixture.SanitizeName(string.Empty));
+
+ // Assert
+ exception.ShouldNotBeNull();
+ }
+
+ // Never instantiated: only exists so SanitizeDatabaseName (protected static) can be called through a concrete
+ // closed-generic subclass. PostgreSqlBuilder/PostgreSqlContainer are used purely as generic-constraint
+ // witnesses — no container or Docker daemon is ever involved.
+ public sealed class FakeDatabaseFixture(IMessageSink messageSink)
+ : TestDatabaseContainerFixture(messageSink)
+ {
+ protected override PostgreSqlBuilder Configure() => throw new NotSupportedException();
+
+ protected override IDbAdapter DbAdapter => throw new NotSupportedException();
+
+ public override DbProviderFactory DbProviderFactory => throw new NotSupportedException();
+
+ public override string ConnectionStringKey => throw new NotSupportedException();
+
+ public static string SanitizeName(string scopeId) => SanitizeDatabaseName(scopeId);
+ }
+
+ public sealed class FakeDbContext : DbContext;
+}
diff --git a/tests/Vulthil.xUnit.Tests/Http/HttpMockTests.cs b/tests/Vulthil.xUnit.Tests/Http/HttpMockTests.cs
new file mode 100644
index 00000000..b64d945b
--- /dev/null
+++ b/tests/Vulthil.xUnit.Tests/Http/HttpMockTests.cs
@@ -0,0 +1,249 @@
+using System.Net;
+using System.Net.Http.Json;
+using Vulthil.xUnit.Http;
+
+namespace Vulthil.xUnit.Tests.Http;
+
+public sealed class HttpMockTests : BaseUnitTestCase
+{
+ // HttpMock is internal, so it cannot be the generic Target of BaseUnitTestCase (that would leak an internal
+ // type into this public class's base-list). This mirrors the same lazy-creation pattern privately instead.
+#pragma warning disable IDE0032 // Lazily created via CreateInstance, not a plain auto property.
+ private HttpMock? _target;
+#pragma warning restore IDE0032
+
+ private HttpMock Target => _target ??= CreateInstance();
+
+ [Fact]
+ public async Task ExactPathMatchReturnsTheConfiguredResponse()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/users/1").RespondWith(HttpStatusCode.OK, new TestPayload("Ada"));
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/users/1"), CancellationToken);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync(CancellationToken);
+ payload.ShouldNotBeNull();
+ payload.Name.ShouldBe("Ada");
+ }
+
+ [Fact]
+ public async Task JsonResponseBodyUsesCamelCasePropertyNaming()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/users/1").RespondWith(HttpStatusCode.OK, new TestPayload("Ada"));
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/users/1"), CancellationToken);
+
+ // Assert
+ var json = await response.Content.ReadAsStringAsync(CancellationToken);
+ json.ShouldContain("\"name\"");
+ }
+
+ [Fact]
+ public async Task WildcardSegmentMatchesAnyValueButAnchorsTheRemainderOfThePath()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/users/*/repos").RespondWith(HttpStatusCode.OK);
+ using var client = CreateClient();
+
+ // Act
+ using var matched = await client.GetAsync(Url("/users/42/repos"), CancellationToken);
+ using var unmatched = await client.GetAsync(Url("/users/42/repos/extra"), CancellationToken);
+
+ // Assert
+ matched.StatusCode.ShouldBe(HttpStatusCode.OK);
+ unmatched.StatusCode.ShouldBe(HttpStatusCode.NotImplemented);
+ }
+
+ [Fact]
+ public async Task DifferentMethodOnAMatchingPathDoesNotMatch()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/ping").RespondWith(HttpStatusCode.OK);
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.PostAsync(Url("/ping"), content: null, CancellationToken);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.NotImplemented);
+ }
+
+ [Fact]
+ public async Task PredicateOverloadMatchesArbitraryCriteria()
+ {
+ // Arrange
+ Target.On(request => request.Method == HttpMethod.Post && request.RequestUri!.AbsolutePath == "/custom")
+ .RespondWith(HttpStatusCode.Accepted);
+ using var client = CreateClient();
+
+ // Act
+ using var matched = await client.PostAsync(Url("/custom"), content: null, CancellationToken);
+ using var unmatched = await client.GetAsync(Url("/custom"), CancellationToken);
+
+ // Assert
+ matched.StatusCode.ShouldBe(HttpStatusCode.Accepted);
+ unmatched.StatusCode.ShouldBe(HttpStatusCode.NotImplemented);
+ }
+
+ [Fact]
+ public async Task UnmatchedRequestReturnsNotImplementedWithADiagnosticBody()
+ {
+ // Arrange
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/nowhere"), CancellationToken);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.NotImplemented);
+ response.ReasonPhrase.ShouldBe("No matching HTTP mock");
+ var body = await response.Content.ReadAsStringAsync(CancellationToken);
+ body.ShouldContain("GET");
+ body.ShouldContain("/nowhere");
+ }
+
+ [Fact]
+ public async Task FirstMatchingRuleWinsWhenMultipleRulesOverlap()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/x").RespondWith(HttpStatusCode.OK);
+ Target.On(HttpMethod.Get, "/x").RespondWith(HttpStatusCode.Accepted);
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/x"), CancellationToken);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task ReceivedRequestsCapturesEveryRequestInOrderIncludingUnmatchedOnes()
+ {
+ // Arrange
+ Target.On(HttpMethod.Post, "/echo").RespondWith(HttpStatusCode.OK);
+ using var client = CreateClient();
+ using var echoContent = new StringContent("hello");
+
+ // Act
+ using var first = await client.GetAsync(Url("/unmatched"), CancellationToken);
+ using var second = await client.PostAsync(Url("/echo"), echoContent, CancellationToken);
+
+ // Assert
+ Target.ReceivedRequests.Count.ShouldBe(2);
+ Target.ReceivedRequests[0].Method.ShouldBe(HttpMethod.Get);
+ Target.ReceivedRequests[0].RequestUri.ShouldNotBeNull();
+ Target.ReceivedRequests[0].RequestUri!.AbsolutePath.ShouldBe("/unmatched");
+ Target.ReceivedRequests[1].Method.ShouldBe(HttpMethod.Post);
+ Target.ReceivedRequests[1].Body.ShouldBe("hello");
+ }
+
+ [Fact]
+ public async Task ResetAsyncClearsBothConfiguredRulesAndReceivedRequests()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/ping").RespondWith(HttpStatusCode.OK);
+ using var client = CreateClient();
+ using var beforeReset = await client.GetAsync(Url("/ping"), CancellationToken);
+
+ // Act
+ await Target.ResetAsync(serviceProvider: null!);
+
+ // Assert
+ Target.ReceivedRequests.ShouldBeEmpty();
+ using var afterReset = await client.GetAsync(Url("/ping"), CancellationToken);
+ beforeReset.StatusCode.ShouldBe(HttpStatusCode.OK);
+ afterReset.StatusCode.ShouldBe(HttpStatusCode.NotImplemented);
+ }
+
+ [Fact]
+ public async Task ACustomHeaderIsAddedToTheResponseHeaders()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/ping").RespondWith(HttpStatusCode.OK).WithHeader("X-Custom", "value1", "value2");
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/ping"), CancellationToken);
+
+ // Assert
+ response.Headers.GetValues("X-Custom").ShouldBe(["value1", "value2"]);
+ }
+
+ [Fact]
+ public async Task AContentHeaderFallsBackToTheContentHeadersWhenResponseHeadersRejectIt()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/download")
+ .RespondWith(HttpStatusCode.OK, new TestPayload("Ada"))
+ .WithHeader("Content-Disposition", "attachment; filename=test.json");
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/download"), CancellationToken);
+
+ // Assert
+ response.Headers.Any(header => header.Key == "Content-Disposition").ShouldBeFalse();
+ response.Content.Headers.GetValues("Content-Disposition").ShouldBe(["attachment; filename=test.json"]);
+ }
+
+ [Fact]
+ public async Task RespondWithJsonReturnsTheSuppliedBodyVerbatim()
+ {
+ // Arrange
+ const string rawJson = """{"literal":true}""";
+ Target.On(HttpMethod.Get, "/raw").RespondWithJson(HttpStatusCode.OK, rawJson);
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/raw"), CancellationToken);
+
+ // Assert
+ var body = await response.Content.ReadAsStringAsync(CancellationToken);
+ body.ShouldBe(rawJson);
+ }
+
+ [Fact]
+ public async Task RespondWithACustomResponderGrantsFullControlOverTheResponse()
+ {
+ // Arrange
+ Target.On(HttpMethod.Get, "/full-control")
+ .RespondWith((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)));
+ using var client = CreateClient();
+
+ // Act
+ using var response = await client.GetAsync(Url("/full-control"), CancellationToken);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.Created);
+ }
+
+ [Fact]
+ public void CapturedHttpRequestExposesTheValuesItWasConstructedWith()
+ {
+ // Arrange
+ var uri = Url("/x");
+
+ // Act
+ var captured = new CapturedHttpRequest(HttpMethod.Put, uri, "body");
+
+ // Assert
+ captured.Method.ShouldBe(HttpMethod.Put);
+ captured.RequestUri.ShouldBe(uri);
+ captured.Body.ShouldBe("body");
+ }
+
+ private static Uri Url(string path) => new($"http://localhost{path}");
+
+ private HttpClient CreateClient() => new(Target.CreateHandler());
+
+ public sealed record TestPayload(string Name);
+}
diff --git a/tests/Vulthil.xUnit.Tests/Vulthil.xUnit.Tests.csproj b/tests/Vulthil.xUnit.Tests/Vulthil.xUnit.Tests.csproj
new file mode 100644
index 00000000..34439c86
--- /dev/null
+++ b/tests/Vulthil.xUnit.Tests/Vulthil.xUnit.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+