From d5e352079afebaacc496aa7693c9e283d1ea19ef Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Mon, 27 Oct 2025 10:05:26 +0100 Subject: [PATCH] WIP: unit test coveage for writefilehttpresponse --- .../Http/WriteFileHttpResponseTests.cs | 814 ++++++++++++++++++ 1 file changed, 814 insertions(+) create mode 100644 test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs diff --git a/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs new file mode 100644 index 000000000..a3e9c6734 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs @@ -0,0 +1,814 @@ +using System.Text; +using System.IO.Compression; +using Elsa.Common; +using Elsa.Http; +using Elsa.Http.ContentWriters; +using Elsa.Http.Parsers; +using Elsa.Http.Options; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Http; + +public class WriteFileHttpResponseTests +{ + [Theory] + [InlineData("text/plain", "test.txt")] + [InlineData("application/pdf", "document.pdf")] + [InlineData("image/jpeg", "photo.jpg")] + [InlineData("application/zip", "archive.zip")] + public async Task Should_Set_Correct_Content_Type_And_Filename(string contentType, string filename) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + activity.ContentType = new Input(contentType); + activity.Filename = new Input(filename); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal(contentType, httpContext.Response.ContentType); + Assert.Contains($"filename=\"{filename}\"", httpContext.Response.Headers.ContentDisposition.ToString()); + } + + [Theory] + [InlineData("Hello World")] + [InlineData("")] + public async Task Should_Handle_String_Content(string content) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + activity.Content = new Input(content); + activity.Filename = new Input("test.txt"); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + var responseContent = GetResponseContent(httpContext); + Assert.Equal(content, responseContent); + } + + [Fact] + public async Task Should_Handle_Byte_Array_Content() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testBytes = "Hello World"u8.ToArray(); + + activity.Content = new Input(testBytes); + activity.Filename = new Input("test.bin"); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + var responseBytes = GetResponseBytes(httpContext); + Assert.Equal(testBytes, responseBytes); + } + + [Fact] + public async Task Should_Handle_Stream_Content() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Stream content"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); + + activity.Content = new Input(stream); + activity.Filename = new Input("stream.txt"); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + var responseContent = GetResponseContent(httpContext); + Assert.Equal(testContent, responseContent); + } + + [Fact] + public async Task Should_Handle_Uri_Content() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testUri = new Uri("https://example.com/file.txt"); + + activity.Content = new Input(testUri); + activity.Filename = new Input("downloaded.txt"); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // The actual file download would be mocked through the IDownloadableManager + } + + [Fact] + public async Task Should_Handle_Multiple_Files_As_Zip() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var files = new object[] + { + "File 1 content", + "File 2 content"u8.ToArray() + }; + + activity.Content = new Input(files); + activity.Filename = new Input("archive.zip"); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal("application/zip", httpContext.Response.ContentType); + Assert.Contains("filename=\"archive.zip\"", httpContext.Response.Headers.ContentDisposition.ToString()); + } + + [Fact] + public async Task Should_Return_NoContent_When_Content_Is_Null() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + activity.Content = new Input((object?)null!, string.Empty); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal(StatusCodes.Status204NoContent, httpContext.Response.StatusCode); + } + + [Fact] + public async Task Should_Return_NoContent_When_Content_Is_Empty_Array() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + activity.Content = new Input(Array.Empty()); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal(StatusCodes.Status204NoContent, httpContext.Response.StatusCode); + } + + [Theory] + [InlineData("\"12345\"")] + [InlineData("\"abcdef\"")] + [InlineData("W/\"weak-etag\"")] + public async Task Should_Set_Entity_Tag_Header(string entityTag) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + activity.EntityTag = new Input(entityTag); + activity.EnableResumableDownloads = new Input(true); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + + // For weak ETags, the response header format may differ from the input + if (entityTag.StartsWith("W/")) + { + // Weak ETags should be present in the response, but format might be normalized + Assert.True(httpContext.Response.Headers.ContainsKey("ETag")); + var responseETag = httpContext.Response.Headers.ETag.ToString(); + Assert.True(responseETag.StartsWith("W/") || responseETag.Contains("weak-etag")); + } + else + { + // Strong ETags should match exactly + Assert.Equal(entityTag, httpContext.Response.Headers.ETag.ToString()); + } + } + + [Fact] + public async Task Should_Enable_Range_Processing_When_Resumable_Downloads_Enabled() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + activity.EnableResumableDownloads = new Input(true); + activity.EntityTag = new Input("\"test-etag\""); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // Range processing and ETag would be handled by FileStreamResult + Assert.True(httpContext.Response.Headers.ContainsKey("ETag")); + } + + [Fact] + public async Task Should_Not_Set_ETag_When_Resumable_Downloads_Disabled() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + activity.EnableResumableDownloads = new Input(false); + activity.EntityTag = new Input("\"test-etag\""); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.False(httpContext.Response.Headers.ContainsKey("ETag")); + } + + [Theory] + [InlineData("test-correlation-id")] + [InlineData("workflow-def-123")] + [InlineData("custom-download-id")] + public async Task Should_Use_Download_Correlation_Id(string correlationId) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var files = new object[] { "File 1", "File 2" }; + + activity.Content = new Input(files); + activity.DownloadCorrelationId = new Input(correlationId); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // The correlation ID would be used internally by ZipManager + } + + [Fact] + public async Task Should_Use_X_Download_Id_Header_When_No_Correlation_Id_Set() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var files = new object[] { "File 1", "File 2" }; + var expectedDownloadId = "header-download-id"; + + httpContext.Request.Headers["x-download-id"] = expectedDownloadId; + activity.Content = new Input(files); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // The header value would be used internally + } + + [Theory] + [InlineData("file.txt", "text/plain")] + [InlineData("document.pdf", "application/pdf")] + [InlineData("image.jpg", "image/jpeg")] + [InlineData("archive.zip", "application/zip")] + [InlineData("unknown.xyz", "application/octet-stream")] + public async Task Should_Determine_Content_Type_From_Filename_When_Not_Specified(string filename, string expectedContentType) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + activity.Filename = new Input(filename); + // ContentType not set, should be determined from filename + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal(expectedContentType, httpContext.Response.ContentType); + } + + [Fact] + public async Task Should_Use_Default_Filename_When_Not_Specified() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + + activity.Content = new Input(testContent); + // Filename not set, should default to "file.bin" + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Contains("filename=\"file.bin\"", httpContext.Response.Headers.ContentDisposition.ToString()); + } + + [Fact] + public async Task Should_Handle_Range_Header_For_Partial_Content() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World Content for Range Request"u8.ToArray(); + + httpContext.Request.Headers["Range"] = "bytes=0-4"; + activity.Content = new Input(testContent); + activity.EnableResumableDownloads = new Input(true); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // Range processing would be handled by FileStreamResult + } + + [Fact] + public async Task Should_Handle_If_Match_Header() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = "Hello World"u8.ToArray(); + var etag = "\"test-etag\""; + + httpContext.Request.Headers["If-Match"] = etag; + activity.Content = new Input(testContent); + activity.EntityTag = new Input(etag); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // If-Match processing would be handled by the downloadable manager + } + + [Fact] + public async Task Should_Create_Bookmark_When_No_HttpContext_Available() + { + // Arrange + var activity = new WriteFileHttpResponse(); + var fixture = new ActivityTestFixture(activity); + fixture.ConfigureServices(services => + { + var mockHttpContextAccessor = Substitute.For(); + mockHttpContextAccessor.HttpContext.Returns((HttpContext?)null); + services.AddSingleton(mockHttpContextAccessor); + AddMockServices(services); + }); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.False(context.IsCompleted); + var bookmarks = context.WorkflowExecutionContext.Bookmarks.ToList(); + Assert.Single(bookmarks); + } + + [Fact] + public async Task Should_Resume_From_Bookmark_With_HttpContext() + { + // Arrange + var activity = new WriteFileHttpResponse(); + var testContent = "Hello World"u8.ToArray(); + activity.Content = new Input(testContent); + + var fixture = new ActivityTestFixture(activity); + var httpContext = CreateMockHttpContext(); + + // First execution - should create bookmark + fixture.ConfigureServices(services => + { + var mockHttpContextAccessor = Substitute.For(); + mockHttpContextAccessor.HttpContext.Returns((HttpContext?)null); + services.AddSingleton(mockHttpContextAccessor); + AddMockServices(services); + }); + + var firstContext = await fixture.ExecuteAsync(); + Assert.False(firstContext.IsCompleted); + + // Resume execution with HttpContext available + fixture.ConfigureServices(services => + { + var newHttpContextAccessor = Substitute.For(); + newHttpContextAccessor.HttpContext.Returns(httpContext); + services.AddSingleton(newHttpContextAccessor); + AddMockServices(services); + }); + + // Act - simulate resume by executing with bookmark context + var newFixture = new ActivityTestFixture(activity); + newFixture.ConfigureServices(services => + { + var resumeHttpContextAccessor = Substitute.For(); + resumeHttpContextAccessor.HttpContext.Returns(httpContext); + services.AddSingleton(resumeHttpContextAccessor); + AddMockServices(services); + }); + + var resumedContext = await newFixture.ExecuteAsync(); + + // Assert + Assert.True(resumedContext.IsCompleted); + } + + [Fact] + public async Task Should_Handle_Downloadable_Object_With_Metadata() + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var downloadable = new Downloadable + { + Stream = new MemoryStream("Test content"u8.ToArray()), + Filename = "metadata-file.txt", + ContentType = "text/plain", + ETag = "\"metadata-etag\"" + }; + + activity.Content = new Input(downloadable); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + Assert.Equal("text/plain", httpContext.Response.ContentType); + Assert.Contains("filename=\"metadata-file.txt\"", httpContext.Response.Headers.ContentDisposition.ToString()); + } + + [Theory] + [InlineData("bytes=0-499")] + [InlineData("bytes=500-999")] + [InlineData("bytes=-500")] + public async Task Should_Handle_Various_Range_Headers(string rangeHeader) + { + // Arrange + var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); + var testContent = new byte[1000]; // 1KB of data + new Random().NextBytes(testContent); + + httpContext.Request.Headers["Range"] = rangeHeader; + activity.Content = new Input(testContent); + activity.EnableResumableDownloads = new Input(true); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(context.IsCompleted); + // Range processing would be handled by FileStreamResult internally + } + + // Helper Methods + private static (WriteFileHttpResponse activity, HttpContext httpContext) CreateWriteFileHttpResponseActivity() + { + var activity = new WriteFileHttpResponse(); + var httpContext = new DefaultHttpContext(); + + // Create a service collection with the required services for FileStreamResult + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + // Add required services for FileStreamResult + services.AddSingleton>( + _ => new FileStreamResultExecutor( + LoggerFactory.Create(b => b.AddConsole()) + ) + ); + + // Build the service provider and assign it to HttpContext + var serviceProvider = services.BuildServiceProvider(); + httpContext.RequestServices = serviceProvider; + + httpContext.Response.Body = new MemoryStream(); + + return (activity, httpContext); + } + + private static HttpContext CreateMockHttpContext() + { + var httpContext = new DefaultHttpContext(); + + // Create a service collection with the required services for FileStreamResult + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + // Add required services for FileStreamResult + services.AddSingleton>( + _ => new FileStreamResultExecutor( + LoggerFactory.Create(b => b.AddConsole()) + ) + ); + + // Build the service provider and assign it to HttpContext + var serviceProvider = services.BuildServiceProvider(); + httpContext.RequestServices = serviceProvider; + + httpContext.Response.Body = new MemoryStream(); + + return httpContext; + } + + private static async Task ExecuteActivityAsync(WriteFileHttpResponse activity, HttpContext httpContext) + { + var fixture = new ActivityTestFixture(activity); + fixture.ConfigureServices(services => + { + var mockHttpContextAccessor = Substitute.For(); + mockHttpContextAccessor.HttpContext.Returns(httpContext); + services.AddSingleton(mockHttpContextAccessor); + services.AddSingleton(CreateMockHttpActivityOptions()); + AddHttpContentFactories(services); + AddHttpContentParsers(services); + AddMockServices(services); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + }); + + return await fixture.ExecuteAsync(); + } + + private static Microsoft.Extensions.Options.IOptions CreateMockHttpActivityOptions() + { + var options = new HttpActivityOptions + { + WriteHttpResponseSynchronously = false + }; + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(options); + return mockOptions; + } + + private static void AddHttpContentFactories(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + private static void AddHttpContentParsers(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + private static void AddMockServices(IServiceCollection services) + { + // Mock IDownloadableManager + var mockDownloadableManager = Substitute.For(); + mockDownloadableManager.GetDownloadablesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var content = callInfo.ArgAt(0); + return CreateMockDownloadables(content).Select(d => new Func>(() => ValueTask.FromResult(d))); + }); + services.AddSingleton(mockDownloadableManager); + + // Mock IFileCacheStorageProvider - keep it simple since methods may not exist + var mockFileCacheProvider = Substitute.For(); + services.AddSingleton(mockFileCacheProvider); + + // Mock HttpFileCacheOptions + var mockFileCacheOptions = Substitute.For>(); + mockFileCacheOptions.Value.Returns(new HttpFileCacheOptions + { + TimeToLive = TimeSpan.FromHours(1) + }); + services.AddSingleton(mockFileCacheOptions); + + // Mock ISystemClock - use the correct Elsa interface + var mockSystemClock = Substitute.For(); + mockSystemClock.UtcNow.Returns(DateTimeOffset.UtcNow); + services.AddSingleton(mockSystemClock); + + // Add IStimulusHasher service required for bookmark creation + services.AddSingleton(Substitute.For()); + + // Add logging + services.AddLogging(); + + // Register ZipManager - ensure it's always available for the tests + var zipManagerType = typeof(WriteFileHttpResponse).Assembly.GetType("Elsa.Http.Services.ZipManager"); + if (zipManagerType != null) + { + services.AddSingleton(zipManagerType, serviceProvider => + { + try + { + var systemClock = serviceProvider.GetRequiredService(); + var fileCacheProvider = serviceProvider.GetRequiredService(); + var fileCacheOptions = serviceProvider.GetRequiredService>(); + + var constructor = zipManagerType.GetConstructors().FirstOrDefault(); + if (constructor != null) + { + var parameters = constructor.GetParameters(); + var args = new object[parameters.Length]; + + for (int i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + if (paramType == typeof(ISystemClock)) + args[i] = systemClock; + else if (paramType == typeof(IFileCacheStorageProvider)) + args[i] = fileCacheProvider; + else if (paramType == typeof(Microsoft.Extensions.Options.IOptions)) + args[i] = fileCacheOptions; + else if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == typeof(ILogger<>)) + { + // Create a properly typed logger mock + var loggerType = paramType; + var mockLoggerMethod = typeof(Substitute).GetMethods() + .Where(m => m.Name == "For" && m.IsGenericMethodDefinition && m.GetParameters().Length == 0) + .FirstOrDefault(); + if (mockLoggerMethod != null) + { + var genericMethod = mockLoggerMethod.MakeGenericMethod(loggerType); + args[i] = genericMethod.Invoke(null, null) ?? Substitute.For(); + } + else + { + args[i] = Substitute.For(); + } + } + else if (paramType == typeof(ILogger)) + args[i] = Substitute.For(); + else + { + // Create a mock for the parameter type using the correct overload + try + { + args[i] = Substitute.For(new[] { paramType }, Array.Empty()); + } + catch + { + args[i] = null!; // Use null for types that can't be mocked + } + } + } + + return constructor.Invoke(args); + } + } + catch + { + // If real ZipManager creation fails, create a mock that implements the same interface + // This ensures the service is always available for dependency injection + } + + // Fallback: create a mock implementation using NSubstitute + return Substitute.For(new[] { zipManagerType }, Array.Empty()); + }); + } + else + { + // If ZipManager type is not found, create a generic mock object + // Register it as object type so it can be resolved + services.AddSingleton(_ => new object()); + } + } + + private static IEnumerable CreateMockDownloadables(object content) + { + // Simplified mock creation for different content types + if (content is byte[] byteArray) + { + yield return new Downloadable + { + Stream = new MemoryStream(byteArray), + ContentType = "application/octet-stream", + Filename = "file.bin" + }; + } + else if (content is string text) + { + yield return new Downloadable + { + Stream = new MemoryStream(Encoding.UTF8.GetBytes(text)), + ContentType = "text/plain", + Filename = "file.txt" + }; + } + else if (content is Stream stream) + { + // For Stream objects, return them as-is + yield return new Downloadable + { + Stream = stream, + ContentType = "application/octet-stream", + Filename = "stream.bin" + }; + } + else if (content is Uri uri) + { + // For Uri objects, simulate downloaded content + var mockContent = $"Downloaded content from {uri}"; + yield return new Downloadable + { + Stream = new MemoryStream(Encoding.UTF8.GetBytes(mockContent)), + ContentType = "text/plain", + Filename = Path.GetFileName(uri.LocalPath) ?? "downloaded.txt" + }; + } + else if (content is Downloadable downloadable) + { + // For Downloadable objects, return them as-is + yield return downloadable; + } + else if (content is IEnumerable fileContents) + { + // For collections, create a zip downloadable + var zipDownloadable = CreateZipDownloadable(fileContents); + yield return zipDownloadable; + } + else + { + throw new NotSupportedException("Unsupported content type"); + } + } + + private static Downloadable CreateZipDownloadable(IEnumerable fileContents) + { + // Don't use 'using' here to avoid disposing the stream prematurely + var memoryStream = new MemoryStream(); + + try + { + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var fileIndex = 0; + foreach (var fileContent in fileContents) + { + var entryName = $"file{fileIndex++}.txt"; + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using (var entryStream = entry.Open()) + { + var contentBytes = Encoding.UTF8.GetBytes(fileContent?.ToString() ?? ""); + entryStream.Write(contentBytes, 0, contentBytes.Length); + } + } + } + + memoryStream.Position = 0; + return new Downloadable + { + Stream = memoryStream, + ContentType = "application/zip", + Filename = "archive.zip" + }; + } + catch + { + // If something goes wrong, dispose the stream to prevent leaks + memoryStream.Dispose(); + throw; + } + } + + private static string GetResponseContent(HttpContext httpContext) + { + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(httpContext.Response.Body, Encoding.UTF8, leaveOpen: true); + var content = reader.ReadToEnd(); + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); // Reset stream position + return content; + } + + private static byte[] GetResponseBytes(HttpContext httpContext) + { + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var memoryStream = new MemoryStream(); + httpContext.Response.Body.CopyTo(memoryStream); + return memoryStream.ToArray(); + } +}