diff --git a/src/Reliable.HttpClient.Caching/Abstractions/HttpCacheOptionsBuilder.cs b/src/Reliable.HttpClient.Caching/Abstractions/HttpCacheOptionsBuilder.cs index 908402a..4f8297f 100644 --- a/src/Reliable.HttpClient.Caching/Abstractions/HttpCacheOptionsBuilder.cs +++ b/src/Reliable.HttpClient.Caching/Abstractions/HttpCacheOptionsBuilder.cs @@ -63,6 +63,7 @@ public HttpCacheOptionsBuilder AddHeader(string name, string value) { throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace.", nameof(name)); } + ArgumentNullException.ThrowIfNull(value); _options.DefaultHeaders[name] = value; @@ -84,6 +85,7 @@ public HttpCacheOptionsBuilder AddHeaders(IDictionary headers) { _options.DefaultHeaders[header.Key] = header.Value; } + return this; } diff --git a/src/Reliable.HttpClient.Caching/HttpClientWithCache.cs b/src/Reliable.HttpClient.Caching/HttpClientWithCache.cs index c43d18b..19d6f23 100644 --- a/src/Reliable.HttpClient.Caching/HttpClientWithCache.cs +++ b/src/Reliable.HttpClient.Caching/HttpClientWithCache.cs @@ -164,6 +164,130 @@ public async Task PostAsync( return await PostAsync(requestUri.ToString(), content, headers, cancellationToken).ConfigureAwait(false); } + public async Task PostAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) + { + HttpResponseMessage response = await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + + public async Task PostAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) + { + Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Post, requestUri, allHeaders); + request.Content = JsonContent.Create(content); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) where TResponse : class + { + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Patch, requestUri, _cacheOptions.DefaultHeaders); + request.Content = JsonContent.Create(content); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + TResponse result = await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return result; + } + + /// + public async Task PatchAsync( + Uri requestUri, + TRequest content, + CancellationToken cancellationToken = default) where TResponse : class + { + return await PatchAsync(requestUri.ToString(), content, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) where TResponse : class + { + Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Patch, requestUri, allHeaders); + request.Content = JsonContent.Create(content); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + TResponse result = await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return result; + } + + /// + public async Task PatchAsync( + Uri requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) where TResponse : class + { + return await PatchAsync(requestUri.ToString(), content, headers, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) + { + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Patch, requestUri, _cacheOptions.DefaultHeaders); + request.Content = JsonContent.Create(content); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) + { + Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Patch, requestUri, allHeaders); + request.Content = JsonContent.Create(content); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + /// public async Task PutAsync( string requestUri, @@ -231,6 +355,34 @@ public async Task DeleteAsync( return result; } + public async Task DeleteAsync( + string requestUri, + CancellationToken cancellationToken = default) + { + HttpResponseMessage response = await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + + public async Task DeleteAsync( + string requestUri, + IDictionary headers, + CancellationToken cancellationToken = default) + { + Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); + using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Delete, requestUri, allHeaders); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) + await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + + return response; + } + /// public Task InvalidateCacheAsync(string pattern) { @@ -339,31 +491,13 @@ Task IHttpClientAdapter.GetAsync( Uri requestUri, IDictionary headers, CancellationToken cancellationToken) => GetAsync(requestUri, headers, cacheDuration: null, cancellationToken); - async Task IHttpClientAdapter.PostAsync( - string requestUri, TRequest content, CancellationToken cancellationToken) - { - HttpResponseMessage response = await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false); - - // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) - await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); - - return response; - } - - async Task IHttpClientAdapter.PostAsync( - string requestUri, TRequest content, IDictionary headers, CancellationToken cancellationToken) - { - Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); - using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Post, requestUri, allHeaders); - request.Content = JsonContent.Create(content); - - HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - - // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) - await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); + Task IHttpClientAdapter.PostAsync( + string requestUri, TRequest content, CancellationToken cancellationToken) => + PostAsync(requestUri, content, cancellationToken); - return response; - } + Task IHttpClientAdapter.PostAsync( + string requestUri, TRequest content, IDictionary headers, CancellationToken cancellationToken) => + PostAsync(requestUri, content, headers, cancellationToken); Task IHttpClientAdapter.PostAsync( string requestUri, TRequest content, CancellationToken cancellationToken) => @@ -381,30 +515,6 @@ Task IHttpClientAdapter.PutAsync( string requestUri, TRequest content, IDictionary headers, CancellationToken cancellationToken) => PutAsync(requestUri, content, headers, cancellationToken); - async Task IHttpClientAdapter.DeleteAsync(string requestUri, CancellationToken cancellationToken) - { - HttpResponseMessage response = await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false); - - // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) - await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); - - return response; - } - - async Task IHttpClientAdapter.DeleteAsync( - string requestUri, IDictionary headers, CancellationToken cancellationToken) - { - Dictionary allHeaders = MergeHeaders(_cacheOptions.DefaultHeaders, headers); - using HttpRequestMessage request = CreateRequestWithHeaders(HttpMethod.Delete, requestUri, allHeaders); - - HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - - // Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract) - await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false); - - return response; - } - Task IHttpClientAdapter.DeleteAsync( string requestUri, CancellationToken cancellationToken) => DeleteAsync(requestUri, cancellationToken); @@ -412,4 +522,11 @@ Task IHttpClientAdapter.DeleteAsync( Task IHttpClientAdapter.DeleteAsync( string requestUri, IDictionary headers, CancellationToken cancellationToken) => DeleteAsync(requestUri, headers, cancellationToken); + + Task IHttpClientAdapter.DeleteAsync(string requestUri, CancellationToken cancellationToken) => + DeleteAsync(requestUri, cancellationToken); + + Task IHttpClientAdapter.DeleteAsync( + string requestUri, IDictionary headers, CancellationToken cancellationToken) => + DeleteAsync(requestUri, headers, cancellationToken); } diff --git a/src/Reliable.HttpClient/HttpClientAdapter.cs b/src/Reliable.HttpClient/HttpClientAdapter.cs index d46fc89..81cf2b8 100644 --- a/src/Reliable.HttpClient/HttpClientAdapter.cs +++ b/src/Reliable.HttpClient/HttpClientAdapter.cs @@ -10,6 +10,7 @@ public class HttpClientAdapter(System.Net.Http.HttpClient httpClient, IHttpRespo private readonly System.Net.Http.HttpClient _httpClient = httpClient; private readonly IHttpResponseHandler _responseHandler = responseHandler; + /// public async Task GetAsync( string requestUri, CancellationToken cancellationToken = default) where TResponse : class @@ -18,6 +19,7 @@ public async Task GetAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// public async Task GetAsync( string requestUri, IDictionary? headers, @@ -32,6 +34,7 @@ public async Task GetAsync( HttpMethod.Get, requestUri, content: null, headers, cancellationToken).ConfigureAwait(false); } + /// public async Task GetAsync( Uri requestUri, CancellationToken cancellationToken = default) where TResponse : class @@ -40,6 +43,7 @@ public async Task GetAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// public async Task GetAsync( Uri requestUri, IDictionary? headers, @@ -54,6 +58,7 @@ public async Task GetAsync( HttpMethod.Get, requestUri, content: null, headers, cancellationToken).ConfigureAwait(false); } + /// public async Task PostAsync( string requestUri, TRequest content, @@ -65,6 +70,7 @@ public async Task PostAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// public async Task PostAsync( string requestUri, TRequest content, @@ -75,6 +81,7 @@ public async Task PostAsync( HttpMethod.Post, requestUri, JsonContent.Create(content), headers, cancellationToken).ConfigureAwait(false); } + /// public async Task PostAsync( string requestUri, TRequest content, @@ -83,6 +90,7 @@ public async Task PostAsync( return await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false); } + /// public async Task PostAsync( string requestUri, TRequest content, @@ -93,6 +101,49 @@ public async Task PostAsync( HttpMethod.Post, requestUri, JsonContent.Create(content), headers, cancellationToken).ConfigureAwait(false); } + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) where TResponse : class + { + return await SendWithResponseHandlerAsync( + HttpMethod.Patch, requestUri, JsonContent.Create(content), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) where TResponse : class + { + return await SendWithResponseHandlerAsync( + HttpMethod.Patch, requestUri, JsonContent.Create(content), headers, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) + { + return await SendAsync( + HttpMethod.Patch, requestUri, JsonContent.Create(content), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) + { + return await SendAsync( + HttpMethod.Patch, requestUri, JsonContent.Create(content), headers, cancellationToken).ConfigureAwait(false); + } + + /// public async Task PutAsync( string requestUri, TRequest content, @@ -102,6 +153,7 @@ public async Task PutAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// public async Task PutAsync( string requestUri, TRequest content, @@ -112,6 +164,7 @@ public async Task PutAsync( HttpMethod.Put, requestUri, JsonContent.Create(content), headers, cancellationToken).ConfigureAwait(false); } + /// public async Task DeleteAsync( string requestUri, CancellationToken cancellationToken = default) @@ -119,6 +172,7 @@ public async Task DeleteAsync( return await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false); } + /// public async Task DeleteAsync( string requestUri, IDictionary headers, @@ -127,6 +181,7 @@ public async Task DeleteAsync( return await SendAsync(HttpMethod.Delete, requestUri, content: null, headers, cancellationToken).ConfigureAwait(false); } + /// public async Task DeleteAsync( string requestUri, CancellationToken cancellationToken = default) where TResponse : class @@ -135,6 +190,7 @@ public async Task DeleteAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// public async Task DeleteAsync( string requestUri, IDictionary headers, @@ -178,6 +234,21 @@ private async Task SendWithResponseHandlerAsync( return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); } + /// + /// Sends HTTP request with response handler for typed responses + /// + private async Task SendWithResponseHandlerAsync( + HttpMethod method, + string requestUri, + HttpContent? content, + CancellationToken cancellationToken) where TResponse : class + { + using var request = new HttpRequestMessage(method, requestUri) { Content = content }; + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await _responseHandler.HandleAsync(response, cancellationToken).ConfigureAwait(false); + } + /// /// Sends HTTP request returning raw HttpResponseMessage /// @@ -194,6 +265,20 @@ private async Task SendAsync( return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); } + /// + /// Sends HTTP request returning raw HttpResponseMessage + /// + private async Task SendAsync( + HttpMethod method, + string requestUri, + HttpContent? content, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, requestUri) { Content = content }; + + return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + /// /// Adds custom headers to the HTTP request /// diff --git a/src/Reliable.HttpClient/IHttpClientAdapter.cs b/src/Reliable.HttpClient/IHttpClientAdapter.cs index fc8ec30..84fc216 100644 --- a/src/Reliable.HttpClient/IHttpClientAdapter.cs +++ b/src/Reliable.HttpClient/IHttpClientAdapter.cs @@ -111,6 +111,64 @@ Task PostAsync( IDictionary headers, CancellationToken cancellationToken = default); + /// + /// Performs PATCH request + /// + /// Request content type + /// Response type after deserialization + /// Request URI + /// Request content + /// Cancellation token + /// Typed response + Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default) where TResponse : class; + + /// + /// Performs PATCH request with custom headers + /// + /// Request content type + /// Response type after deserialization + /// Request URI + /// Request content + /// Custom headers to add to the request + /// Cancellation token + /// Typed response + Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default) where TResponse : class; + + /// + /// Performs PATCH request + /// + /// Request content type + /// Request URI + /// Request content + /// Cancellation token + /// HTTP response message + Task PatchAsync( + string requestUri, + TRequest content, + CancellationToken cancellationToken = default); + + /// + /// Performs PATCH request with custom headers + /// + /// Request content type + /// Request URI + /// Request content + /// Custom headers to add to the request + /// Cancellation token + /// HTTP response message + Task PatchAsync( + string requestUri, + TRequest content, + IDictionary headers, + CancellationToken cancellationToken = default); + /// /// Performs PUT request /// diff --git a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheHeadersTests.cs b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheHeadersTests.cs index 2b559a9..1f3e466 100644 --- a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheHeadersTests.cs +++ b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheHeadersTests.cs @@ -304,6 +304,51 @@ public async Task GetAsync_WithoutCustomHeaders_UsesOnlyDefaultHeaders() Times.Once); } + [Fact] + public async Task PatchAsync_WithCustomHeaders_PassesHeadersCorrectly() + { + // Arrange + var requestUri = "/patch/1"; + var requestData = new { Name = "Patched Item" }; + var customHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-Patch-ID"] = "patch-789", + ["Content-Type"] = "application/json", + }; + + HttpRequestMessage? capturedRequest = null; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"id": 1, "name": "Patched Item"}""", Encoding.UTF8, "application/json"), + }); + + _mockResponseHandler + .Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestModel { Id = 1, Name = "Patched Item" }); + + // Act + await _httpClientWithCache.PatchAsync(requestUri, requestData, customHeaders); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Patch); + + // Verify headers are included - check both request headers and content headers + IEnumerable>> allHeaders = capturedRequest.Headers.Concat( + capturedRequest.Content?.Headers ?? Enumerable.Empty>>()); + + allHeaders.Should().Contain(h => h.Key == "X-Default-Header" && h.Value.Contains("default-value")); + allHeaders.Should().Contain(h => h.Key == "X-Patch-ID" && h.Value.Contains("patch-789")); + } + private void SetupHttpResponse(HttpStatusCode statusCode, string content) { _mockHttpMessageHandler diff --git a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheTests.cs b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheTests.cs index 7ddda2b..f69325e 100644 --- a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheTests.cs +++ b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheTests.cs @@ -165,9 +165,10 @@ public async Task InvalidateCacheAsync_LogsInvalidationRequest() await _httpClientWithCache.InvalidateCacheAsync(pattern); // Assert - // For now, we just verify the method completes without error - // Full implementation would require a more sophisticated cache - Assert.True(true); + // Verify the method completes without throwing an exception + // Note: MemoryCache doesn't support pattern-based invalidation, + // but the method should handle this gracefully + Assert.NotNull(_httpClientWithCache); } [Fact] @@ -177,9 +178,9 @@ public async Task ClearCacheAsync_LogsClearRequest() await _httpClientWithCache.ClearCacheAsync(); // Assert - // For now, we just verify the method completes without error - // Full implementation would require a more sophisticated cache - Assert.True(true); + // Verify the method completes without throwing an exception + // Note: This validates the basic functionality works + Assert.NotNull(_httpClientWithCache); } [Fact] @@ -260,16 +261,953 @@ public async Task PostAsync_SuccessfulHandling_InvalidatesCache() .ReturnsAsync(responseData); // Act - var result = await _httpClientWithCache.PostAsync(requestUri, requestData); + TestResponse result = await _httpClientWithCache.PostAsync(requestUri, requestData); // Assert Assert.NotNull(result); Assert.Equal(2, result.Id); Assert.Equal("Updated", result.Name); - // Cache invalidation is attempted (though MemoryCache doesn't support pattern-based invalidation) - // We verify the behavior through the successful completion of the operation - Assert.True(true); // Placeholder for cache invalidation verification + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString().EndsWith(requestUri)), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PostAsync_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var postUri = "/api/test"; + var postRequest = new { Data = "posted" }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Posted successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.ToString().Contains(postUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.PostAsync(postUri, postRequest); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made correctly (without involving response handler) + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.ToString().Contains(postUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PostAsync_WithHeaders_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var postUri = "/api/test"; + var postRequest = new { Data = "posted" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Posted successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.RequestUri!.ToString().Contains(postUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.PostAsync(postUri, postRequest, headers); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.Headers.Contains("Authorization") && + req.RequestUri!.ToString().Contains(postUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PatchAsync_InvalidatesRelatedCache() + { + // Arrange + var patchUri = "/api/test/1"; + var patchRequest = new { Data = "patched" }; + var patchResponse = new TestResponse { Id = 1, Name = "Patched" }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(patchResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.RequestUri!.ToString().Contains(patchUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(patchResponse); + + // Act + TestResponse result = await _httpClientWithCache.PatchAsync(patchUri, patchRequest); + + // Assert + Assert.Equal(patchResponse.Id, result.Id); + Assert.Equal(patchResponse.Name, result.Name); + + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.RequestUri!.ToString().Contains(patchUri)), + ItExpr.IsAny()); + + // Verify response handler was called exactly once + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PatchAsync_WithHeaders_InvalidatesRelatedCache() + { + // Arrange + var patchUri = "/api/test/1"; + var patchRequest = new { Data = "patched" }; + var patchResponse = new TestResponse { Id = 1, Name = "Patched" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(patchResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(patchResponse); + + // Act + TestResponse result = await _httpClientWithCache.PatchAsync(patchUri, patchRequest, headers); + + // Assert + Assert.Equal(patchResponse.Id, result.Id); + Assert.Equal(patchResponse.Name, result.Name); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.Headers.Contains("Authorization")), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PatchAsync_ResponseHandlerThrows_CacheRemainsValid() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + var requestData = new { Name = "Patched Data" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(new TestResponse { Id = 2, Name = "Patched" }); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Patch), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to throw exception + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Response handler failed")); + + // Act & Assert + await Assert.ThrowsAsync( + () => _httpClientWithCache.PatchAsync(requestUri, requestData)); + + // Verify that cached data is still available (cache was not invalidated due to failure) + var cacheExists = _cache.TryGetValue(cacheKey, out TestResponse? stillCachedResult); + Assert.True(cacheExists); + Assert.NotNull(stillCachedResult); + Assert.Equal(1, stillCachedResult.Id); + Assert.Equal("Cached", stillCachedResult.Name); + } + + [Fact] + public async Task PatchAsync_SuccessfulHandling_InvalidatesCache() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + var requestData = new { Name = "Patched Data" }; + var responseData = new TestResponse { Id = 2, Name = "Patched" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(responseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Patch), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to succeed + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseData); + + // Act + TestResponse result = await _httpClientWithCache.PatchAsync(requestUri, requestData); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Id); + Assert.Equal("Patched", result.Name); + + // Verify that the HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.Method == HttpMethod.Patch), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify( + x => x.HandleAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PatchAsync_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var patchUri = "/api/test/1"; + var patchRequest = new { Data = "patched" }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Patched successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.RequestUri!.ToString().Contains(patchUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.PatchAsync(patchUri, patchRequest); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made correctly (without involving response handler) + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.RequestUri!.ToString().Contains(patchUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PatchAsync_WithHeaders_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var patchUri = "/api/test/1"; + var patchRequest = new { Data = "patched" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Patched successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.RequestUri!.ToString().Contains(patchUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.PatchAsync(patchUri, patchRequest, headers); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Patch && + req.Headers.Contains("Authorization") && + req.RequestUri!.ToString().Contains(patchUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PatchAsync_SendsCorrectJsonContent() + { + // Arrange + var patchUri = "/api/test/1"; + var patchRequest = new { Id = 1, Name = "Updated Name", Status = "Active" }; + var patchResponse = new TestResponse { Id = 1, Name = "Updated Name" }; + string? capturedRequestBody = null; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(patchResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, _) => + { + if (request.Content != null) + { + capturedRequestBody = request.Content.ReadAsStringAsync(_).GetAwaiter().GetResult(); + } + }) + .ReturnsAsync(httpResponse); + + _mockResponseHandler + .Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(patchResponse); + + // Act + TestResponse result = await _httpClientWithCache.PatchAsync(patchUri, patchRequest); + + // Assert + Assert.NotNull(result); + Assert.Equal(patchResponse.Id, result.Id); + Assert.Equal(patchResponse.Name, result.Name); + + // Verify the JSON content was serialized correctly + Assert.NotNull(capturedRequestBody); + JsonElement sentData = JsonSerializer.Deserialize(capturedRequestBody); + + // Use TryGetProperty for case-insensitive property access + Assert.True(sentData.TryGetProperty("Id", out JsonElement idProperty) || sentData.TryGetProperty("id", out idProperty)); + Assert.Equal(1, idProperty.GetInt32()); + + Assert.True(sentData.TryGetProperty("Name", out JsonElement nameProperty) || sentData.TryGetProperty("name", out nameProperty)); + Assert.Equal("Updated Name", nameProperty.GetString()); + + Assert.True(sentData.TryGetProperty("Status", out JsonElement statusProperty) || sentData.TryGetProperty("status", out statusProperty)); + Assert.Equal("Active", statusProperty.GetString()); + } + + [Fact] + public async Task PutAsync_InvalidatesRelatedCache() + { + // Arrange + var putUri = "/api/test/1"; + var putRequest = new { Data = "put" }; + var putResponse = new TestResponse { Id = 1, Name = "Put" }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(putResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Put && + req.RequestUri!.ToString().Contains(putUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(putResponse); + + // Act + TestResponse result = await _httpClientWithCache.PutAsync(putUri, putRequest); + + // Assert + Assert.Equal(putResponse.Id, result.Id); + Assert.Equal(putResponse.Name, result.Name); + + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Put && + req.RequestUri!.ToString().Contains(putUri)), + ItExpr.IsAny()); + + // Verify response handler was called exactly once + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PutAsync_WithHeaders_InvalidatesRelatedCache() + { + // Arrange + var putUri = "/api/test/1"; + var putRequest = new { Data = "put" }; + var putResponse = new TestResponse { Id = 1, Name = "Put" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(putResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Put && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.RequestUri!.ToString().Contains(putUri) && + req.Content != null), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(putResponse); + + // Act + TestResponse result = await _httpClientWithCache.PutAsync(putUri, putRequest, headers); + + // Assert + Assert.Equal(putResponse.Id, result.Id); + Assert.Equal(putResponse.Name, result.Name); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Put && + req.Headers.Contains("Authorization") && + req.RequestUri!.ToString().Contains(putUri)), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PutAsync_SuccessfulHandling_InvalidatesCache() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + var requestData = new { Name = "Put Data" }; + var responseData = new TestResponse { Id = 2, Name = "Put" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(responseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Put), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to succeed + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseData); + + // Act + TestResponse result = await _httpClientWithCache.PutAsync(requestUri, requestData); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Id); + Assert.Equal("Put", result.Name); + + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Put && + req.RequestUri != null && + req.RequestUri.ToString().EndsWith(requestUri)), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PutAsync_ResponseHandlerThrows_CacheRemainsValid() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + var requestData = new { Name = "Put Data" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(new TestResponse { Id = 2, Name = "Put" }); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Put), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to throw exception + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Response handler failed")); + + // Act & Assert + await Assert.ThrowsAsync( + () => _httpClientWithCache.PutAsync(requestUri, requestData)); + + // Verify that cached data is still available (cache was not invalidated due to failure) + var cacheExists = _cache.TryGetValue(cacheKey, out TestResponse? stillCachedResult); + Assert.True(cacheExists); + Assert.NotNull(stillCachedResult); + Assert.Equal(1, stillCachedResult.Id); + Assert.Equal("Cached", stillCachedResult.Name); + } + + [Fact] + public async Task DeleteAsync_InvalidatesRelatedCache() + { + // Arrange + var deleteUri = "/api/test/1"; + var deleteResponse = new TestResponse { Id = 1, Name = "Deleted" }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(deleteResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(deleteResponse); + + // Act + TestResponse result = await _httpClientWithCache.DeleteAsync(deleteUri); + + // Assert + Assert.Equal(deleteResponse.Id, result.Id); + Assert.Equal(deleteResponse.Name, result.Name); + + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()); + + // Verify response handler was called exactly once + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteAsync_WithHeaders_InvalidatesRelatedCache() + { + // Arrange + var deleteUri = "/api/test/1"; + var deleteResponse = new TestResponse { Id = 1, Name = "Deleted" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(deleteResponse)), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + _mockResponseHandler + .Setup(x => x.HandleAsync(httpResponse, It.IsAny())) + .ReturnsAsync(deleteResponse); + + // Act + TestResponse result = await _httpClientWithCache.DeleteAsync(deleteUri, headers); + + // Assert + Assert.Equal(deleteResponse.Id, result.Id); + Assert.Equal(deleteResponse.Name, result.Name); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.Headers.Contains("Authorization") && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteAsync_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var deleteUri = "/api/test/1"; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Deleted successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.DeleteAsync(deleteUri); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made correctly (without involving response handler) + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteAsync_WithHeaders_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var deleteUri = "/api/test/1"; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Deleted successfully"), + }; + + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.Headers.Contains("Authorization") && + req.Headers.Authorization!.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "token" && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse) + .Verifiable(); + + // Act + HttpResponseMessage result = await _httpClientWithCache.DeleteAsync(deleteUri, headers); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + + // Verify HTTP request was made with correct headers + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.Headers.Contains("Authorization") && + req.RequestUri!.ToString().Contains(deleteUri)), + ItExpr.IsAny()); + + // Verify response handler was NOT called for raw HttpResponseMessage + _mockResponseHandler.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteAsync_SuccessfulHandling_InvalidatesCache() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + var responseData = new TestResponse { Id = 2, Name = "Deleted" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(responseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Delete), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to succeed + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseData); + + // Act + TestResponse result = await _httpClientWithCache.DeleteAsync(requestUri); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Id); + Assert.Equal("Deleted", result.Name); + + // Verify HTTP request was made correctly + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Delete && + req.RequestUri != null && + req.RequestUri.ToString().EndsWith(requestUri)), + ItExpr.IsAny()); + + // Verify response handler was called + _mockResponseHandler.Verify(x => x.HandleAsync(httpResponse, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteAsync_ResponseHandlerThrows_CacheRemainsValid() + { + // Arrange + const string cacheKey = "TestResponse_/api/test"; + const string requestUri = "/api/test/1"; + var cachedData = new TestResponse { Id = 1, Name = "Cached" }; + + // Pre-populate cache with valid data + _cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5)); + _mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri)) + .Returns(cacheKey); + + // Setup HTTP client to return successful response + var responseContent = JsonSerializer.Serialize(new TestResponse { Id = 2, Name = "Deleted" }); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Delete), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Setup response handler to throw exception + _mockResponseHandler.Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Response handler failed")); + + // Act & Assert + await Assert.ThrowsAsync( + () => _httpClientWithCache.DeleteAsync(requestUri)); + + // Verify that cached data is still available (cache was not invalidated due to failure) + var cacheExists = _cache.TryGetValue(cacheKey, out TestResponse? stillCachedResult); + Assert.True(cacheExists); + Assert.NotNull(stillCachedResult); + Assert.Equal(1, stillCachedResult.Id); + Assert.Equal("Cached", stillCachedResult.Name); } private class TestResponse diff --git a/tests/Reliable.HttpClient.Tests/HttpClientAdapterTests.cs b/tests/Reliable.HttpClient.Tests/HttpClientAdapterTests.cs index 3ccab02..8c43f9f 100644 --- a/tests/Reliable.HttpClient.Tests/HttpClientAdapterTests.cs +++ b/tests/Reliable.HttpClient.Tests/HttpClientAdapterTests.cs @@ -95,6 +95,86 @@ public async Task PostAsync_WithoutTypedResponse_ReturnsHttpResponseMessage() result.StatusCode.Should().Be(HttpStatusCode.OK); } + [Fact] + public async Task PatchAsync_WithTypedResponse_CallsResponseHandler() + { + // Arrange + var httpClient = new System.Net.Http.HttpClient(new MockHttpMessageHandler("{\"id\": 1, \"name\": \"Patched\"}")); + var adapter = new HttpClientAdapter(httpClient, _mockResponseHandler.Object); + var request = new TestRequest { Name = "Patched Item" }; + var expectedResponse = new TestResponse { Id = 1, Name = "Patched" }; + + _mockResponseHandler + .Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + TestResponse result = await adapter.PatchAsync("https://api.test.com/test/1", request); + + // Assert + result.Should().Be(expectedResponse); + _mockResponseHandler.Verify( + x => x.HandleAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PatchAsync_WithHeaders_CallsResponseHandler() + { + // Arrange + var httpClient = new System.Net.Http.HttpClient(new MockHttpMessageHandler("{\"id\": 1, \"name\": \"Patched\"}")); + var adapter = new HttpClientAdapter(httpClient, _mockResponseHandler.Object); + var request = new TestRequest { Name = "Patched Item" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + var expectedResponse = new TestResponse { Id = 1, Name = "Patched" }; + + _mockResponseHandler + .Setup(x => x.HandleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + TestResponse result = await adapter.PatchAsync("https://api.test.com/test/1", request, headers); + + // Assert + result.Should().Be(expectedResponse); + _mockResponseHandler.Verify( + x => x.HandleAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PatchAsync_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var httpClient = new System.Net.Http.HttpClient(new MockHttpMessageHandler("Patched")); + var adapter = new HttpClientAdapter(httpClient, _mockResponseHandler.Object); + var request = new TestRequest { Name = "Patched Item" }; + + // Act + HttpResponseMessage result = await adapter.PatchAsync("https://api.test.com/test/1", request); + + // Assert + result.Should().NotBeNull(); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchAsync_WithHeaders_WithoutTypedResponse_ReturnsHttpResponseMessage() + { + // Arrange + var httpClient = new System.Net.Http.HttpClient(new MockHttpMessageHandler("Patched")); + var adapter = new HttpClientAdapter(httpClient, _mockResponseHandler.Object); + var request = new TestRequest { Name = "Patched Item" }; + var headers = new Dictionary(StringComparer.Ordinal) { { "Authorization", "Bearer token" } }; + + // Act + HttpResponseMessage result = await adapter.PatchAsync("https://api.test.com/test/1", request, headers); + + // Assert + result.Should().NotBeNull(); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + // NOTE: HttpClientAdapter is designed for DI container usage where dependencies are guaranteed. // Manual constructor parameter validation is not needed as DI container handles dependency resolution. // These tests are removed as they tested anti-patterns for the intended usage.