diff --git a/CHANGELOG.md b/CHANGELOG.md index bc53314..4692568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Represents the **NuGet** versions. +## v5.9.1 +- *Fixed:* The `MockHttpClientRequest` now caches the response content internally, and creates a new `HttpContent` instance for each request to ensure that the content can be read multiple times across multiple requests (where applicable); avoids potential object disposed error. +- *Fixed:* The `MockHttpClient.Reset()` was incorrectly resetting the `MockHttpClient` instance to its default state, but was not resetting the internal request configuration which is used to determine the response. This has now been corrected to reset the internal mocked state only. +- *Fixed:* The `ApiTesterBase` has had `UseSolutionRelativeContentRoot` added to correct the error where the underlying `WebApplicationFactory` was not correctly finding the `appsettings.json` file from the originating solution. + ## v5.9.0 - *Enhancement:* Added `WithGenericTester` (_MSTest_ and _NUnit_ only) class to enable class-level generic tester usage versus one-off. - *Enhancement:* Added `TesterBase.UseScopedTypeSetUp()` to enable a function that will be executed directly before each `ScopedTypeTester{TService}` is instantiated to allow standardized/common set up to occur. diff --git a/Common.targets b/Common.targets index 15e2445..183041f 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 5.9.0 + 5.9.1 preview Avanade Avanade diff --git a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs index 77b0b8f..fa0161d 100644 --- a/src/UnitTestEx/AspNetCore/ApiTesterBase.cs +++ b/src/UnitTestEx/AspNetCore/ApiTesterBase.cs @@ -26,6 +26,7 @@ public abstract class ApiTesterBase : TesterBase, IDi { private bool _disposed; private WebApplicationFactory? _waf; + private string? _solutionRelativePath; /// /// Initializes a new instance of the class. @@ -64,7 +65,7 @@ protected WebApplicationFactory GetWebApplicationFactory() return _waf; _waf = new WebApplicationFactory().WithWebHostBuilder(whb => - whb.UseSolutionRelativeContentRoot(Environment.CurrentDirectory) + whb.UseSolutionRelativeContentRoot(_solutionRelativePath ?? Environment.CurrentDirectory) .ConfigureAppConfiguration((_, cb) => { cb.AddJsonFile("appsettings.unittest.json", optional: true); @@ -164,6 +165,22 @@ protected override void ResetHost() /// The . public TestServer GetTestServer() => HostExecutionWrapper(() => GetWebApplicationFactory().Server); + /// + /// Sets the content root to be relative to the solution directory (i.e. the directory containing the .sln file). + /// + /// The directory of the solution file. + /// The to support fluent-style method-chaining. + /// This is required when the API project is not in the same directory as the test project and ensures that the API project's appsettings.json files are found and used. + /// This is the functional equivalent of . + public TSelf UseSolutionRelativeContentRoot(string? solutionRelativePath) + { + if (_waf != null) + throw new InvalidOperationException("The content root must be set before the WebApplicationFactory is instantiated."); + + _solutionRelativePath = solutionRelativePath; + return (TSelf)this; + } + /// /// Releases all resources. /// diff --git a/src/UnitTestEx/Mocking/MockHttpClient.cs b/src/UnitTestEx/Mocking/MockHttpClient.cs index 32987b6..792a238 100644 --- a/src/UnitTestEx/Mocking/MockHttpClient.cs +++ b/src/UnitTestEx/Mocking/MockHttpClient.cs @@ -290,14 +290,15 @@ public void Verify() } /// - /// Disposes and removes the cached . + /// Resets all the configured requests and related configurations. /// + /// This invokes a to reset the mock state. This includes its setups, configured default return values, registered event handlers, and all recorded invocations. public void Reset() { lock (_lock) { - _httpClient?.Dispose(); - _httpClient = null; + _requests.Clear(); + MessageHandler.Reset(); } } diff --git a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs index 09f4900..caefd27 100644 --- a/src/UnitTestEx/Mocking/MockHttpClientRequest.cs +++ b/src/UnitTestEx/Mocking/MockHttpClientRequest.cs @@ -121,7 +121,24 @@ private static async Task CreateResponseAsync(HttpRequestMe var httpResponse = new HttpResponseMessage(response.StatusCode) { RequestMessage = request }; if (response.Content != null) - httpResponse.Content = response.Content; + { + // Load into buffer to ensure content is available for multiple reads (internal only). +#if NET9_0_OR_GREATER + await response.Content.LoadIntoBufferAsync(ct).ConfigureAwait(false); +#else + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); +#endif + + // Copy content for the response vs. trying to reuse the same instance which may have already been read by the caller and therefore not available for the next call. + var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + httpResponse.Content = new ByteArrayContent(bytes); + + // Copy across the content headers (e.g. Content-Type) to the new content instance. + foreach (var h in response.Content.Headers) + { + httpResponse.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } if (!response.HttpHeaders.IsEmpty) { diff --git a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs index 9e55bc7..74a1b32 100644 --- a/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs +++ b/tests/UnitTestEx.NUnit.Test/MockHttpClientTest.cs @@ -1,15 +1,16 @@ using Moq; +using NUnit.Framework; using System; +using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; using System.Text; using System.Threading.Tasks; -using UnitTestEx.NUnit.Test.Model; using UnitTestEx.NUnit; -using NUnit.Framework; -using System.Diagnostics; -using System.Linq; +using UnitTestEx.NUnit.Test.Model; +using static System.Net.Mime.MediaTypeNames; namespace UnitTestEx.NUnit.Test { @@ -371,6 +372,67 @@ public async Task MockSequenceDelay() }); } + [Test] + public async Task MockReuseSameAndReset() + { + var mcf = MockHttpClientFactory.Create(); + var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); + mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK); + + var hc = mcf.GetHttpClient("XXX"); + var res = await hc.GetAsync("products/xyz").ConfigureAwait(false); + var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("some-some")); + + res = await hc.GetAsync("products/xyz").ConfigureAwait(false); + txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("some-some")); + + // Now let's reset. + mc.Reset(); + + // Should throw as no matched requests. + Console.WriteLine("No-match"); + Assert.ThrowsAsync(async () => await hc.GetAsync("products/xyz").ConfigureAwait(false)); + + // Add the request back in. + mc.Request(HttpMethod.Get, "products/xyz").Respond.With("some-some", HttpStatusCode.OK); + mc.Request(HttpMethod.Get, "products/abc").Respond.With("a-blue-carrot", HttpStatusCode.OK); + + // Try again and should work. + Console.WriteLine("Yes-Match"); + res = await hc.GetAsync("products/xyz").ConfigureAwait(false); + txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("some-some")); + + res = await hc.GetAsync("products/abc").ConfigureAwait(false); + txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("a-blue-carrot")); + } + + [Test] + public async Task MockOnTheFlyChange() + { + var mcf = MockHttpClientFactory.Create(); + var mc = mcf.CreateClient("XXX", new Uri("https://d365test")); + var mcr = mc.Request(HttpMethod.Get, "products/xyz").Respond; + + var hc = mcf.GetHttpClient("XXX"); + + // Set the response. + mcr.With("some-some", HttpStatusCode.OK); + + // Get the response and verify. + var res = await hc.GetAsync("products/xyz").ConfigureAwait(false); + var txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("some-some")); + + mcr.With("some-other", HttpStatusCode.Accepted); + res = await hc.GetAsync("products/xyz").ConfigureAwait(false); + txt = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.That(txt, Is.EqualTo("some-other")); + } + [Test] public async Task UriAndBody_WithXmlRequest() { diff --git a/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs b/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs index a211c0e..cf76872 100644 --- a/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs +++ b/tests/UnitTestEx.NUnit.Test/ProductControllerTest.cs @@ -68,6 +68,7 @@ public void Success2() .Request(HttpMethod.Get, "products/xyz").Respond.WithJson(new { id = "Xyz", description = "Xtra yellow elephant" }); using var test = ApiTester.Create(); + test.UseSolutionRelativeContentRoot("tests/UnitTestEx.Api"); test.ReplaceHttpClientFactory(mcf) .Controller() .Run(c => c.Get("xyz"))