diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs index 58477047..3bcff83f 100644 --- a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -439,6 +439,79 @@ public static IWithResponse Latency(this IWithResponse builder, Func + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, Func bitRate) + { + if (bitRate is null) + { + throw new ArgumentNullException(nameof(bitRate)); + } + + return builder.TransferRate(bitRate()); + } + + /// + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, BitRate bitRate) + { + if (bitRate is null) + { + throw new ArgumentNullException(nameof(bitRate)); + } + + return builder.TransferRate((int)bitRate); + } + + /// + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, int bitRate) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Behaviors.Replace(new TransferRateBehavior(bitRate)); + return builder; + } + private static string? ConvertToString(T v) { switch (v) diff --git a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs index 3d0f6cd4..e72d68c0 100644 --- a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs +++ b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs @@ -119,17 +119,36 @@ public int Compare(IResponseBehavior? x, IResponseBehavior? y) private static int Compare(IResponseBehavior? x, IResponseBehavior? y, bool flipped) { + if (ReferenceEquals(x, null)) + { + return 1; + } + + if (ReferenceEquals(y, null)) + { + return -1; + } + + if (ReferenceEquals(x, y)) + { + return 0; + } + return x switch { // The network latency behavior must always come first. NetworkLatencyBehavior => -1, - _ => CompareOtherWayAround() + // The rate limit behavior must always come first except when the latency behavior is also present. + TransferRateBehavior => y is NetworkLatencyBehavior + ? 1 + : CompareOtherWayAround(-1), + _ => CompareOtherWayAround(0) }; - int CompareOtherWayAround() + int CompareOtherWayAround(int result) { return flipped - ? 0 + ? result #pragma warning disable S2234 // Parameters to 'Compare' have the same names but not the same order as the method arguments. - justification: intentional. : -Compare(y, x, true); #pragma warning restore S2234 diff --git a/src/MockHttp/Responses/BitRate.cs b/src/MockHttp/Responses/BitRate.cs new file mode 100644 index 00000000..252eb1ef --- /dev/null +++ b/src/MockHttp/Responses/BitRate.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using MockHttp.IO; + +// ReSharper disable once CheckNamespace +namespace MockHttp; + +/// +/// Defines different types of bit rates to simulate a slow network. +/// +public sealed class BitRate +{ + private readonly Func _factory; + private readonly string _name; + + private BitRate(Func factory, string name) + { + _factory = factory; + _name = name; + } + + /// + public override string ToString() + { + return $"{GetType().Name}.{_name}"; + } + + /// + /// 2G (mobile network) bit rate. (~64kbps). + /// + public static BitRate TwoG() + { + return Create(64_000, nameof(TwoG)); + } + + /// + /// 3G (mobile network) bit rate. (~2Mbps) + /// + public static BitRate ThreeG() + { + return Create(2_000_000, nameof(ThreeG)); + } + + /// + /// 4G (mobile network) bit rate. (~64Mbps) + /// + public static BitRate FourG() + { + return Create(64_000_000, nameof(FourG)); + } + + /// + /// 5G (mobile network) bit rate. (~512Mbps) + /// + public static BitRate FiveG() + { + return Create(512_000_000, nameof(FiveG)); + } + + /// + /// 10 Mbps. + /// + public static BitRate TenMegabit() + { + return Create(10_000_000, nameof(TenMegabit)); + } + + /// + /// 100 Mbps. + /// + public static BitRate OneHundredMegabit() + { + return Create(100_000_000, nameof(OneHundredMegabit)); + } + + /// + /// 1 Gbps. + /// + public static BitRate OneGigabit() + { + return Create(1_000_000_000, nameof(OneGigabit)); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// + /// + public static explicit operator int (BitRate bitRate) + { + return ToInt32(bitRate); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// + /// + public static explicit operator BitRate (int bitRate) + { + return FromInt32(bitRate); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// The bit rate. + /// The underlying bit rate value. + public static int ToInt32(BitRate bitRate) + { + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + return bitRate?._factory() ?? -1; + } + + /// + /// Convert an integer bit rate (in bits per second) to a . + /// + /// The bit rate. + /// The bit rate. + public static BitRate FromInt32(int bitRate) + { + return Create(bitRate, FormatBps(bitRate)); + } + + private static BitRate Create(int bitRate, string name) + { + if (bitRate <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bitRate)); + } + + return new BitRate(() => bitRate, name); + } + + private static string FormatBps(long value) + { + return BpsToString().ToString(CultureInfo.InvariantCulture); + + FormattableString BpsToString() + { + return value switch + { + < 1_000 => $"Around({value}bps)", + < 1_000_000 => $"Around({(double)value / 1_000:#.##}kbps)", + < 1_000_000_000 => $"Around({(double)value / 1_000_000:#.##}Mbps)", + _ => $"Around({(double)value / 1_000_000_000:#.##}Gbps)" + }; + } + } +} diff --git a/src/MockHttp/Responses/TransferRateBehavior.cs b/src/MockHttp/Responses/TransferRateBehavior.cs new file mode 100644 index 00000000..94a3d9b2 --- /dev/null +++ b/src/MockHttp/Responses/TransferRateBehavior.cs @@ -0,0 +1,65 @@ +using System.Net; +using MockHttp.IO; + +namespace MockHttp.Responses; + +internal sealed class TransferRateBehavior : IResponseBehavior +{ + private readonly int _bitRate; + + public TransferRateBehavior(int bitRate) + { + if (bitRate < RateLimitedStream.MinBitRate) + { + throw new ArgumentOutOfRangeException(nameof(bitRate), $"Bit rate must be higher than or equal to {RateLimitedStream.MinBitRate}."); + } + + _bitRate = bitRate; + } + + public async Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + await next(requestContext, responseMessage, cancellationToken).ConfigureAwait(false); + responseMessage.Content = new RateLimitedHttpContent(responseMessage.Content, _bitRate); + } + + private sealed class RateLimitedHttpContent : HttpContent + { + private readonly int _bitRate; + private readonly HttpContent _originalContent; + + internal RateLimitedHttpContent(HttpContent originalContent, int bitRate) + { + _originalContent = originalContent; + _bitRate = bitRate; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + Stream originalStream = await _originalContent.ReadAsStreamAsync().ConfigureAwait(false); + var rateLimitedStream = new RateLimitedStream(originalStream, _bitRate); +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await using (originalStream) + await using (rateLimitedStream) +#else + using (originalStream) + using (rateLimitedStream) +#endif + { + await rateLimitedStream.CopyToAsync(stream).ConfigureAwait(false); + } + } + + protected override bool TryComputeLength(out long length) + { + long? contentLength = _originalContent.Headers.ContentLength; + length = 0; + if (contentLength.HasValue) + { + length = contentLength.Value; + } + + return contentLength.HasValue; + } + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs b/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs index 12e6ac26..5c11f882 100644 --- a/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs +++ b/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs @@ -141,6 +141,21 @@ public void Given_null_argument_when_executing_method_it_should_throw(params obj responseBuilder, new XDocument(), (XmlWriterSettings?)null + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + BitRate.FourG() + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + BitRate.FourG + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + (int)BitRate.FourG() ) }; diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs index a7335660..30986e52 100644 --- a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs @@ -5,30 +5,35 @@ namespace MockHttp.Language.Flow.Response; -public sealed class TransferRateSpec : ResponseSpec +public class TransferRateSpec : ResponseSpec { private const int DataSizeInBytes = 256 * 1024; // 256 KB - private const int BitRate = 512000; // 512 kbps = 64 KB/s + protected const int BitRate = 1_024_000; // 1024 kbps = 128 KB/s private static readonly TimeSpan ExpectedTotalTime = TimeSpan.FromSeconds(DataSizeInBytes / ((double)BitRate / 8)); - private readonly byte[] _content = Enumerable.Range(0, DataSizeInBytes) + protected readonly byte[] Content = Enumerable.Range(0, DataSizeInBytes) .Select((_, index) => (byte)(index % 256)) .ToArray(); private readonly Stopwatch _stopwatch = new(); + protected override Task When(HttpClient httpClient) + { + _stopwatch.Restart(); + return base.When(httpClient); + } + protected override void Given(IResponseBuilder with) { - with.Body(new RateLimitedStream(new MemoryStream(_content), BitRate)); - _stopwatch.Start(); + with.Body(new RateLimitedStream(new MemoryStream(Content), BitRate)); } - protected override async Task Should(HttpResponseMessage response) + protected sealed override async Task Should(HttpResponseMessage response) { _stopwatch.Stop(); _stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(ExpectedTotalTime); response.Should().HaveStatusCode(HttpStatusCode.OK); byte[]? responseContent = await response.Content.ReadAsByteArrayAsync(); - responseContent.Should().BeEquivalentTo(_content, opts => opts.WithStrictOrdering()); + responseContent.Should().BeEquivalentTo(Content, opts => opts.WithStrictOrdering()); } } diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs new file mode 100644 index 00000000..acb80041 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs @@ -0,0 +1,15 @@ +using static MockHttp.BitRate; +using static MockHttp.NetworkLatency; + +namespace MockHttp.Language.Flow.Response; + +public class TransferRateSpec2 : TransferRateSpec +{ + protected override void Given(IResponseBuilder with) + { + with + .Body(Content) + .Latency(FourG) + .TransferRate(() => FromInt32(BitRate)); + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs new file mode 100644 index 00000000..5ddadbbe --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs @@ -0,0 +1,18 @@ +namespace MockHttp.Language.Flow.Response; + +public class TransferRateWithMultipleRequestsSpec : TransferRateSpec2 +{ + protected override async Task When(HttpClient httpClient) + { + // Perform 5 requests in parallel. + HttpResponseMessage[] result = await Task.WhenAll( + base.When(httpClient), + base.When(httpClient), + base.When(httpClient), + base.When(httpClient), + base.When(httpClient) + ); + + return result.Last(); + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs new file mode 100644 index 00000000..63232415 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs @@ -0,0 +1,20 @@ +using MockHttp.IO; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public sealed class TransferRateWithOutOfRangeBitRateSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty()) + .TransferRate(RateLimitedStream.MinBitRate - 1); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("bitRate"); + } +} diff --git a/test/MockHttp.Tests/MockHttpHandlerTests.cs b/test/MockHttp.Tests/MockHttpHandlerTests.cs index bce5bae8..bef966b0 100644 --- a/test/MockHttp.Tests/MockHttpHandlerTests.cs +++ b/test/MockHttp.Tests/MockHttpHandlerTests.cs @@ -376,6 +376,7 @@ public async Task Given_a_request_expectation_when_sending_requests_it_should_co .Respond(with => with .StatusCode(HttpStatusCode.Accepted) .Body(JsonConvert.SerializeObject(new { firstName = "John", lastName = "Doe" })) + .TransferRate(BitRate.TwoG) .Latency(NetworkLatency.TwoG) ) .Verifiable(); diff --git a/test/MockHttp.Tests/Responses/BitRateTests.cs b/test/MockHttp.Tests/Responses/BitRateTests.cs new file mode 100644 index 00000000..3cf2aba2 --- /dev/null +++ b/test/MockHttp.Tests/Responses/BitRateTests.cs @@ -0,0 +1,113 @@ +using static MockHttp.BitRate; + +namespace MockHttp.Responses; + +public sealed class BitRateTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Given_that_value_is_invalid_when_casting_it_should_throw(int bitRate) + { + // ACt + Func act = () => (BitRate)bitRate; + + // Assert + act.Should() + .Throw() + .WithParameterName(nameof(bitRate)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Given_that_value_is_invalid_when_converting_it_should_throw(int bitRate) + { + // ACt + Func act = () => FromInt32(bitRate); + + // Assert + act.Should() + .Throw() + .WithParameterName(nameof(bitRate)); + } + + [Theory] + [InlineData(1_000)] + [InlineData(1_234_567)] + public void When_converting_to_int_it_should_return_expected(int bitRate) + { + BitRate sut = FromInt32(bitRate); + + // Act + int actual = ToInt32(sut); + + // Assert + actual.Should().Be(bitRate); + } + + [Theory] + [InlineData(1_000)] + [InlineData(1_234_567)] + public void When_casting_to_int_it_should_return_expected(int bitRate) + { + BitRate sut = FromInt32(bitRate); + + // Act + int actual = (int)sut; + + // Assert + actual.Should().Be(bitRate); + } + + [Theory] + [MemberData(nameof(GetBitRateTestCases))] + public void When_creating_using_builtin_factories_it_should_return_expected(BitRate bitRate, int expectedBitRate) + { + ToInt32(bitRate).Should().Be(expectedBitRate); + } + + public static IEnumerable GetBitRateTestCases() + { + yield return new object[] { TwoG(), 64_000 }; + yield return new object[] { ThreeG(), 2_000_000 }; + yield return new object[] { FourG(), 64_000_000 }; + yield return new object[] { FiveG(), 512_000_000 }; + yield return new object[] { TenMegabit(), 10_000_000 }; + yield return new object[] { OneHundredMegabit(), 100_000_000 }; + yield return new object[] { OneGigabit(), 1_000_000_000 }; + yield return new object[] { FromInt32(999), 999 }; + yield return new object[] { FromInt32(1_000), 1_000 }; + yield return new object[] { FromInt32(1_234), 1_234 }; + yield return new object[] { FromInt32(1_000_000), 1_000_000 }; + yield return new object[] { FromInt32(1_234_567), 1_234_567 }; + yield return new object[] { FromInt32(1_000_000_000), 1_000_000_000 }; + yield return new object[] { FromInt32(1_234_567_890), 1_234_567_890 }; + } + + [Theory] + [MemberData(nameof(GetBitRatePrettyTextTestCases))] + public void When_formatting_it_should_return_expected(BitRate bitRate, string expectedPrettyText) + { + bitRate.ToString().Should().Be(expectedPrettyText); + } + + public static IEnumerable GetBitRatePrettyTextTestCases() + { + const string prefix = nameof(BitRate) + "."; + yield return new object[] { TwoG(), prefix + nameof(TwoG) }; + yield return new object[] { ThreeG(), prefix + nameof(ThreeG) }; + yield return new object[] { FourG(), prefix + nameof(FourG) }; + yield return new object[] { FiveG(), prefix + nameof(FiveG) }; + yield return new object[] { TenMegabit(), prefix + nameof(TenMegabit) }; + yield return new object[] { OneHundredMegabit(), prefix + nameof(OneHundredMegabit) }; + yield return new object[] { OneGigabit(), prefix + nameof(OneGigabit) }; + yield return new object[] { FromInt32(999), prefix + "Around(999bps)" }; + yield return new object[] { FromInt32(1_000), prefix + "Around(1kbps)" }; + yield return new object[] { FromInt32(1_234), prefix + "Around(1.23kbps)" }; + yield return new object[] { FromInt32(1_000_000), prefix + "Around(1Mbps)" }; + yield return new object[] { FromInt32(1_234_567), prefix + "Around(1.23Mbps)" }; + yield return new object[] { FromInt32(1_000_000_000), prefix + "Around(1Gbps)" }; + yield return new object[] { FromInt32(1_234_567_890), prefix + "Around(1.23Gbps)" }; + } +}