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 + + +