From b0d93d347b6bebd4bc4172bb80f177ec0681a5db Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 24 Nov 2025 14:37:06 +0100 Subject: [PATCH] Add unit and integration tests for `DownloadHttpFile` activity (#7094) * Add unit and integration tests for `DownloadHttpFile` activity - Developed comprehensive unit tests for the `DownloadHttpFile` activity validating method execution (GET, POST), URL handling, headers, and status codes. - Added integration tests to verify functionality like authorization headers, filename extraction, and response stream handling. - Updated `IntegrationTests` project references to include the required `Elsa.Http` module. * Refactor `DownloadHttpFileTests` to simplify test setup and improve reusability - Consolidated test initialization logic into `RunActivityAsync` for cleaner and reusable test setup. - Updated existing tests to use `RunActivityAsync`, reducing boilerplate code. - Unified filename extraction tests into a parameterized theory to improve test coverage and clarity. - Simplified helper handlers with expression-bodied members for readability. * Add comments to clarify HttpResponseMessage disposal responsibility in `DownloadHttpFileTests`. * Ensure `HttpResponseMessage.Content` is always set to prevent null reference issues in `DownloadHttpFileTests`. * Refactor `DownloadHttpFileTests` to ensure `HttpResponseMessage.Content` is always initialized and simplify content assignment logic. * Update `DownloadHttpFileTests` to set an empty `HttpResponseMessage.Content` for testing `HasContent` behavior * Add `GitHubActionsTestLogger` package to test projects and refactor `Directory.Packages.props` - Updated unit and integration test projects to include `GitHubActionsTestLogger` for improved test reporting in CI. - Refactored `Directory.Packages.props` to add conditional dependencies for .NET 8, 9, and 10 compatibility. * Remove `GitHubActionsTestLogger` package from test projects. * Add project reference for unit tests and update `DownloadHttpFileTests` - Included `Elsa.Activities.UnitTests` project reference in `IntegrationTests` to reuse helpers. - Removed redundant `TestHttpMessageHandler` by utilizing shared helper from `UnitTests`. * Refactor `DownloadHttpFileTests` to remove unused methods and simplify imports * Update target framework to .NET 10 and upgrade `Nuke.Components` package to v10.0.0 --- .github/workflows/packages.yml | 1 + Directory.Packages.props | 361 +++++++++--------- build/_build.csproj | 9 +- .../Elsa.Activities.IntegrationTests.csproj | 4 +- .../Http/DownloadHttpFileTests.cs | 224 +++++++++++ .../Elsa.Alterations.IntegrationTests.csproj | 18 +- .../Elsa.Common.IntegrationTests.csproj | 6 +- .../Elsa.Workflows.IntegrationTests.csproj | 1 + .../Elsa.Workflows.PerformanceTests.csproj | 2 - .../Http/DownloadHttpFileTests.cs | 332 ++++++++++++++++ 10 files changed, 747 insertions(+), 211 deletions(-) create mode 100644 test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index b9801c2f0..760b28067 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -36,6 +36,7 @@ jobs: dotnet-version: | 8.0.x 9.0.x + 10.0.x - name: Cache NuGet packages uses: actions/cache@v4 diff --git a/Directory.Packages.props b/Directory.Packages.props index 23eb221dd..01970e991 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,191 +1,174 @@ - - true - false - - - 10.0.0 - 10.0.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/_build.csproj b/build/_build.csproj index c004aa8d9..bd32549c9 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006 .. @@ -19,12 +19,7 @@ - - - - - - + diff --git a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj index 511e3c7ff..5f9082087 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj +++ b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj @@ -8,7 +8,9 @@ + + - + diff --git a/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs new file mode 100644 index 000000000..5c5fc3cfb --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs @@ -0,0 +1,224 @@ +using System.Net; +using Elsa.Activities.UnitTests.Http.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Http; + +public class DownloadHttpFileTests(ITestOutputHelper testOutputHelper) +{ + [Fact(DisplayName = "DownloadHttpFile downloads file successfully")] + public async Task DownloadsFile_Successfully() + { + // Arrange + var fileContent = "Test file content"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent, "document.pdf", "application/pdf"); + + // Act + var (workflowResult, activity) = await RunActivityAsync("https://example.com/file.pdf", handler); + + // Assert + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + Assert.Equal("document.pdf", file.Filename); + Assert.Equal("application/pdf", file.ContentType); + + var stream = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseContentStream)); + Assert.NotNull(stream); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + Assert.Equal("Test file content", content); + } + + [Fact(DisplayName = "DownloadHttpFile handles POST requests")] + public async Task HandlesPostRequest() + { + // Arrange + var fileContent = "Response content"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent); + + // Act + var (workflowResult, activity) = await RunActivityAsync( + "https://api.example.com/download", + handler, + method: "POST", + requestContent: new { id = 123 }, + requestContentType: "application/json"); + + // Assert + var statusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode)); + Assert.Equal(200, statusCode); + + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + } + + [Fact(DisplayName = "DownloadHttpFile includes authorization header")] + public async Task IncludesAuthorizationHeader() + { + // Arrange + var requestCapture = new HttpRequestMessage?[1]; + var fileContent = "Authorized content"u8.ToArray(); + var handler = CreateCapturingHandler(fileContent, requestCapture); + + // Act + await RunActivityAsync( + "https://example.com/secure/file.pdf", + handler, + authorization: "Bearer test-token"); + + // Assert + var capturedRequest = requestCapture[0]; + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Headers.Authorization); + Assert.Equal("Bearer test-token", capturedRequest.Headers.Authorization.ToString()); + } + + [Theory(DisplayName = "DownloadHttpFile extracts filename correctly")] + [InlineData("https://example.com/generate-report", "annual-report.xlsx", "annual-report.xlsx")] // From Content-Disposition + [InlineData("https://example.com/downloads/image.png", null, "image.png")] // From URL + public async Task ExtractsFilename(string url, string? contentDispositionFilename, string expectedFilename) + { + // Arrange + var fileContent = "File data"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent, contentDispositionFilename); + + // Act + var (workflowResult, activity) = await RunActivityAsync(url, handler); + + // Assert + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Theory(DisplayName = "DownloadHttpFile handles various status codes")] + [InlineData(200)] + [InlineData(201)] + [InlineData(404)] + public async Task HandlesStatusCodes(int statusCode) + { + // Arrange + var handler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode); + + // Act + var (workflowResult, activity) = await RunActivityAsync( + "https://example.com/file.pdf", + handler, + expectedStatusCodes: [statusCode]); + + // Assert + var actualStatusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode)); + Assert.Equal(statusCode, actualStatusCode); + } + + [Fact(DisplayName = "DownloadHttpFile sets response headers")] + public async Task SetsResponseHeaders() + { + // Arrange + var additionalHeaders = new Dictionary + { + { "X-Rate-Limit", "100" }, + { "X-Request-Id", "abc123" } + }; + var handler = CreateFileResponseHandler(additionalHeaders: additionalHeaders); + + // Act + var (workflowResult, activity) = await RunActivityAsync("https://api.example.com/download", handler); + + // Assert + var responseHeaders = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseHeaders)); + Assert.NotNull(responseHeaders); + Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Rate-Limit", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase)); + } + + private async Task<(RunWorkflowResult Result, DownloadHttpFile Activity)> RunActivityAsync( + string url, + HttpMessageHandler handler, + string method = "GET", + object? requestContent = null, + string? requestContentType = null, + string? authorization = null, + int[]? expectedStatusCodes = null) + { + var fixture = CreateFixture(handler); + var activity = new DownloadHttpFile + { + Url = new(new Uri(url)), + Method = new(method), + ExpectedStatusCodes = new(expectedStatusCodes ?? [200]) + }; + + if (requestContent != null) + activity.RequestContent = new(requestContent); + + if (requestContentType != null) + activity.RequestContentType = new(requestContentType); + + if (authorization != null) + activity.Authorization = new(authorization); + + var result = await fixture.RunActivityAsync(activity); + return (result, activity); + } + + private WorkflowTestFixture CreateFixture(HttpMessageHandler handler) => + new WorkflowTestFixture(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseHttp(http => + { + http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler); + })); + + private static HttpMessageHandler CreateFileResponseHandler( + byte[]? content = null, + string? filename = null, + string? contentType = null, + HttpStatusCode statusCode = HttpStatusCode.OK, + Dictionary? additionalHeaders = null) => + new TestHttpMessageHandler((_, _) => + { + // Response ownership is transferred to the caller (HttpClient), which will dispose it + var response = new HttpResponseMessage(statusCode); + + // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers + var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []); + var actualContentType = contentType ?? "application/octet-stream"; + response.Content = new ByteArrayContent(actualContent); + response.Content.Headers.ContentType = new(actualContentType); + + if (filename != null) + { + response.Content.Headers.ContentDisposition = new("attachment") + { + FileName = filename + }; + } + + if (additionalHeaders != null) + foreach (var header in additionalHeaders) + response.Headers.Add(header.Key, header.Value); + + return Task.FromResult(response); + }); + + private static HttpMessageHandler CreateCapturingHandler(byte[] content, HttpRequestMessage?[] capture) => + new TestHttpMessageHandler((request, _) => + { + capture[0] = request; + + // Response ownership is transferred to the caller (HttpClient), which will dispose it + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content) + }; + response.Content.Headers.ContentType = new("application/octet-stream"); + + return Task.FromResult(response); + }); +} diff --git a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj index c93acbf44..e749c8d4a 100644 --- a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj +++ b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj @@ -5,18 +5,18 @@ - - - + + + - - Always - - - Always - + + Always + + + Always + diff --git a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj index cc3bc2079..3cc54f7ff 100644 --- a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj +++ b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj @@ -6,8 +6,8 @@ - - + + - + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index 9fa6481ce..43f34a790 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -91,4 +91,5 @@ Always + diff --git a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj index 57ec54ffe..9f0c772af 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj +++ b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj @@ -1,7 +1,5 @@ - - diff --git a/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs new file mode 100644 index 000000000..301c5e1ee --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs @@ -0,0 +1,332 @@ +using System.Net; +using Elsa.Activities.UnitTests.Http.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using static Elsa.Activities.UnitTests.Http.Helpers.SendHttpRequestTestHelpers; + +namespace Elsa.Activities.UnitTests.Http; + +public class DownloadHttpFileTests +{ + [Theory] + [InlineData("GET", "https://example.com/file.pdf")] + [InlineData("POST", "https://api.example.com/download")] + [InlineData("PUT", "https://api.example.com/update-file")] + public async Task ExecuteAsync_SendsRequest_WithCorrectMethodAndUrl(string method, string url) + { + // Arrange + var expectedUrl = new Uri(url); + var expectedMethod = new HttpMethod(method); + var requestCapture = new RequestCapture(); + var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture); + var activity = CreateActivity(expectedUrl, method); + + // Act + await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method); + Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri); + } + + [Theory] + [InlineData("Bearer token123")] + [InlineData("Basic YWRtaW46cGFzcw==")] + public async Task ExecuteAsync_AddsAuthorizationHeader_WhenProvided(string authorizationHeader) + { + // Arrange + var requestCapture = new RequestCapture(); + var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture); + var activity = CreateActivity(new("https://example.com/file.pdf"), authorization: authorizationHeader); + + // Act + await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization); + Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString()); + } + + [Theory] + [InlineData(200)] + [InlineData(201)] + [InlineData(404)] + public async Task ExecuteAsync_SetsStatusCodeOutput(int statusCode) + { + // Arrange + var responseHandler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var actualStatusCode = context.GetActivityOutput(() => activity.StatusCode); + Assert.Equal(statusCode, actualStatusCode); + } + + [Fact] + public async Task ExecuteAsync_SetsFileOutput_WithCorrectFilename() + { + // Arrange + const string expectedFilename = "document.pdf"; + var responseHandler = CreateFileResponseHandler(filename: expectedFilename); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Fact] + public async Task ExecuteAsync_SetsFileOutput_WithCorrectContentType() + { + // Arrange + const string expectedContentType = "application/pdf"; + var responseHandler = CreateFileResponseHandler(contentType: expectedContentType); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedContentType, file.ContentType); + } + + [Fact] + public async Task ExecuteAsync_SetsResponseContentStream_FromFile() + { + // Arrange + var fileContent = "Test file content"u8.ToArray(); + var responseHandler = CreateFileResponseHandler(content: fileContent); + var activity = CreateActivity(new("https://example.com/file.txt")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var stream = context.GetActivityOutput(() => activity.ResponseContentStream) as Stream; + Assert.NotNull(stream); + + using var reader = new StreamReader(stream); + var actualContent = await reader.ReadToEndAsync(); + Assert.Equal("Test file content", actualContent); + } + + [Fact] + public async Task ExecuteAsync_SetsResponseHeaders() + { + // Arrange + var expectedHeaders = new Dictionary + { + { "X-Custom-Header", "CustomValue" }, + { "X-Request-Id", "12345" } + }; + var responseHandler = CreateFileResponseHandler(additionalHeaders: expectedHeaders); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var responseHeaders = context.GetActivityOutput(() => activity.ResponseHeaders); + Assert.NotNull(responseHeaders); + var httpHeaders = Assert.IsType(responseHeaders); + Assert.True(httpHeaders.Count > 0); + Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Custom-Header", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ExecuteAsync_CompletesSuccessfully() + { + // Arrange + var responseHandler = CreateFileResponseHandler(); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + [Fact] + public async Task ExecuteAsync_HandlesHttpRequestException() + { + // Arrange + var responseHandler = CreateExceptionHandler("Connection failed"); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.JournalData.ContainsKey("Error")); + } + + [Fact] + public async Task ExecuteAsync_HandlesTaskCanceledException() + { + // Arrange + var responseHandler = CreateExceptionHandler("Request timed out"); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.JournalData.ContainsKey("Cancelled")); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenResponseHasNoContent() + { + // Arrange + var responseHandler = CreateEmptyResponseHandler(); + var activity = CreateActivity(new("https://example.com/empty")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result); + Assert.Null(file); + + var stream = context.GetActivityOutput(() => activity.ResponseContentStream); + Assert.Null(stream); + } + + [Fact] + public async Task ExecuteAsync_ExtractsFilename_FromContentDisposition() + { + // Arrange + const string expectedFilename = "report.xlsx"; + var responseHandler = CreateFileResponseHandler(filename: expectedFilename); + var activity = CreateActivity(new("https://example.com/download")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Fact] + public async Task ExecuteAsync_ExtractsFilename_FromUrlSegment_WhenNoContentDisposition() + { + // Arrange + var responseHandler = CreateFileResponseHandler(filename: null); // No Content-Disposition + var activity = CreateActivity(new("https://example.com/files/document.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal("document.pdf", file.Filename); + } + + [Theory] + [InlineData("https://example.com", "/")] + [InlineData("https://example.com/", "/")] + [InlineData("https://example.com/download", "download")] + public async Task ExecuteAsync_ExtractsFilename_FromUrlWhenNoContentDisposition(string url, string expectedFilename) + { + // Arrange + var responseHandler = CreateFileResponseHandler(filename: null); + var activity = CreateActivity(new Uri(url)); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + private static DownloadHttpFile CreateActivity( + Uri url, + string method = "GET", + string? authorization = null) => + new() + { + Url = new(url), + Method = new(method), + Authorization = authorization != null ? new(authorization) : null!, + ExpectedStatusCodes = new([200]) + }; + + private static Task ExecuteAsync( + DownloadHttpFile activity, + Func> responseHandler) => + new ActivityTestFixture(activity).WithHttpServices(responseHandler).ExecuteAsync(); + + private static Func> CreateFileResponseHandler( + HttpStatusCode statusCode = HttpStatusCode.OK, + byte[]? content = null, + string? filename = null, + string? contentType = null, + Dictionary? additionalHeaders = null, + RequestCapture? requestCapture = null) + { + return (request, _) => + { + requestCapture?.CapturedRequest = request; + + var response = new HttpResponseMessage(statusCode); + + // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers + var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []); + var actualContentType = contentType ?? "application/octet-stream"; + response.Content = new ByteArrayContent(actualContent); + response.Content.Headers.ContentType = new(actualContentType); + + if (filename != null) + { + response.Content.Headers.ContentDisposition = new("attachment") + { + FileName = filename + }; + } + + if (additionalHeaders != null) + { + foreach (var header in additionalHeaders) + { + response.Headers.Add(header.Key, header.Value); + } + } + + return Task.FromResult(response); + }; + } + + private static Func> CreateEmptyResponseHandler() + { + return (_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + // Set empty content (ContentLength = 0) to test HasContent returning false + response.Content = new ByteArrayContent(Array.Empty()); + response.Content.Headers.ContentLength = 0; + return Task.FromResult(response); + }; + } +}