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 Vulthil.SharedKernel.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@
<Project Path="tests/Vulthil.IntegrationTests/Vulthil.IntegrationTests.csproj" />
<Project Path="tests/Vulthil.TestHost/Vulthil.TestHost.csproj" />
</Folder>
<Folder Name="/tests/Testing/">
<Project Path="tests/Vulthil.Extensions.Testing.Tests/Vulthil.Extensions.Testing.Tests.csproj" />
<Project Path="tests/Vulthil.xUnit.Tests/Vulthil.xUnit.Tests.csproj" />
</Folder>
<Folder Name="/tests/MessagingIntegrationTests/">
<File Path="tests/MessagingIntegrationTests/aspire.config.json" />
<File Path="tests/MessagingIntegrationTests/Directory.Build.props" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TestPayload>(CancellationToken);

// Assert
payload.Name.ShouldBe("Ada");
}

[Fact]
public async Task NullResponseThrowsArgumentNullException()
{
// Arrange
HttpResponseMessage? response = null;

// Act
var exception = await Should.ThrowAsync<ArgumentNullException>(
() => response.GetResponseAsync<TestPayload>(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<HttpRequestException>(
() => response.GetResponseAsync<TestPayload>(CancellationToken));

// Assert
exception.ShouldNotBeNull();
}

[Fact]
public async Task NullJsonBodyThrowsInvalidOperationException()
{
// Arrange
using var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create<TestPayload?>(null),
};

// Act
var exception = await Should.ThrowAsync<InvalidOperationException>(
() => response.GetResponseAsync<TestPayload>(CancellationToken));

// Assert
exception.Message.ShouldBe("Response content is empty or could not be deserialized.");
}

public sealed record TestPayload(string Name);
}
253 changes: 253 additions & 0 deletions tests/Vulthil.Extensions.Testing.Tests/PollingTests.cs
Original file line number Diff line number Diff line change
@@ -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<Result<int>> 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<Result<string>> Poll(CancellationToken ct)
{
attempt++;
return Task.FromResult(attempt < 3
? Result.Failure<string>(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<Result<int>> Poll(CancellationToken ct)
{
attempt++;
return Task.FromResult(Result.Failure<int>(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<int>(
TimeSpan.FromMilliseconds(40),
_ => Task.FromResult(Result.Failure<int>(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<Result<int>> Poll(CancellationToken ct)
{
observedToken = ct;
return Task.FromResult(Result.Failure<int>(Error.Failure("still-going", "not yet")));
}
externalCts.CancelAfter(TimeSpan.FromMilliseconds(30));

// Act
var exception = await Should.ThrowAsync<OperationCanceledException>(
() => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));

// Assert
exception.ShouldNotBeNull();
observedToken.IsCancellationRequested.ShouldBeTrue();
}

[Fact]
public async Task NullFuncThrowsArgumentNullException()
{
// Arrange
Func<CancellationToken, Task<Result<int>>>? func = null;

// Act
var exception = await Should.ThrowAsync<ArgumentNullException>(
() => 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<Result> 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<Result> 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<Result> 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<Result> 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<OperationCanceledException>(
() => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));

// Assert
exception.ShouldNotBeNull();
observedToken.IsCancellationRequested.ShouldBeTrue();
}

[Fact]
public async Task NullFuncThrowsArgumentNullException()
{
// Arrange
Func<CancellationToken, Task<Result>>? func = null;

// Act
var exception = await Should.ThrowAsync<ArgumentNullException>(
() => Polling.WaitAsync(TimeSpan.FromSeconds(1), func!, CancellationToken));

// Assert
exception.ParamName.ShouldBe("func");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="$([MSBuild]::GetPathOfFileAbove('Vulthil.TestRunner.props', '$(MSBuildThisFileDirectory)'))" />

<ItemGroup>
<ProjectReference Include="..\..\src\Vulthil.Extensions.Testing\Vulthil.Extensions.Testing.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using Vulthil.xUnit;

namespace Vulthil.SharedKernel.Tests.Testing;
namespace Vulthil.xUnit.Tests;

public sealed class BaseUnitTestCaseDisposalTests : BaseUnitTestCase<BaseUnitTestCaseDisposalTests.DisposableTarget>
{
Expand Down
Loading