* Add OpenTelemetry workflow instrumentation * Fix workflow telemetry metric tags * Tighten telemetry test listeners * Refine workflow telemetry boundaries * Address telemetry review feedback * Complete workflow telemetry coverage * Address telemetry instrumentation review feedback * Handle cancelled workflow telemetry * Refine workflow activity telemetry tags * Address telemetry review feedback * Document OpenTelemetry extension coexistence Agent-Logs-Url: https://github.com/elsa-workflows/elsa-core/sessions/33211c71-c3c9-424c-b7eb-a13ebd4713a3 Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Address telemetry PR review comments * Address telemetry review follow-ups * Preserve workflow executing status transition order * Address telemetry review feedback * Refine workflow telemetry review fixes * Address telemetry review feedback * Address workflow instrumentation review feedback * Fix faulted workflow telemetry tags * Restrict workflow exception mutation * Fix canceled activity telemetry status * Clarify workflow exception access * Cover HTTP trace context propagation * Report cancelled workflow telemetry consistently * Refine telemetry cancellation classification * Record thrown workflow exceptions on context * Tighten workflow telemetry exception handling * Preserve first workflow exception * Handle workflow cancellation separately * Clarify workflow telemetry enum references --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
300 lines
12 KiB
C#
300 lines
12 KiB
C#
using System.Net;
|
|
using Elsa.Activities.UnitTests.Http.Helpers;
|
|
using Elsa.Extensions;
|
|
using Elsa.Http;
|
|
using Elsa.Testing.Shared;
|
|
using Elsa.Workflows;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Activities.UnitTests.Http;
|
|
|
|
public class SendHttpRequestTests
|
|
{
|
|
[Theory]
|
|
[InlineData("GET", "https://api.example.com/data", "{\"result\": \"success\"}", 200)]
|
|
[InlineData("POST", "https://api.example.com/create", "{\"id\": 123}", 201)]
|
|
[InlineData("PUT", "https://api.example.com/update", "{\"updated\": true}", 200)]
|
|
public async Task Should_Send_Request_And_Handle_Success_Response(string method, string url, string jsonResponse, int expectedStatusCode)
|
|
{
|
|
// Arrange
|
|
var expectedUrl = new Uri(url);
|
|
var expectedMethod = new HttpMethod(method);
|
|
var expectedHttpStatusCode = (HttpStatusCode)expectedStatusCode;
|
|
var requestCapture = new RequestCapture();
|
|
var responseHandler = CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture);
|
|
var sendHttpRequest = CreateSendHttpRequest(expectedUrl, method);
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert
|
|
Assert.NotNull(requestCapture.CapturedRequest);
|
|
Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method);
|
|
Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri);
|
|
|
|
var statusCodeOutput = context.GetActivityOutput(() => sendHttpRequest.StatusCode);
|
|
Assert.Equal(expectedStatusCode, statusCodeOutput);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Bearer token123")]
|
|
[InlineData("Basic YWRtaW46cGFzcw==")]
|
|
[InlineData("ApiKey abc123")]
|
|
public async Task Should_Add_Authorization_Header(string authorizationHeader)
|
|
{
|
|
// Arrange
|
|
var expectedUrl = new Uri("https://api.example.com/secure");
|
|
|
|
var requestCapture = new RequestCapture();
|
|
var responseHandler = CreateResponseHandler(HttpStatusCode.OK, null, requestCapture);
|
|
var sendHttpRequest = CreateSendHttpRequest(expectedUrl, authorization: authorizationHeader);
|
|
|
|
// Act
|
|
await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert
|
|
Assert.NotNull(requestCapture.CapturedRequest);
|
|
Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization);
|
|
Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Propagate_Current_Trace_Context()
|
|
{
|
|
await SendHttpRequestTestHelpers.AssertPropagatesCurrentTraceContextAsync(async (url, responseHandler) =>
|
|
{
|
|
var sendHttpRequest = CreateSendHttpRequest(url);
|
|
await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
});
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.NotFound, "mockActivity404")]
|
|
[InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.InternalServerError, "mockUnmatchedActivity")]
|
|
public async Task Should_Schedule_Activity_According_To_Handlers(int[] statusCodes, string[] activityNames, string handler, HttpStatusCode expectedStatusCode, string expectedScheduledActivityName)
|
|
{
|
|
// Arrange
|
|
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithStatusHandlers(
|
|
expectedStatusCodes: [(statusCodes[0], activityNames[0]), (statusCodes[1], activityNames[1])],
|
|
unmatchedHandler: handler
|
|
);
|
|
|
|
var responseHandler = CreateResponseHandler(expectedStatusCode);
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert that the correct activity was scheduled.
|
|
var expectedActivity = childActivities[expectedScheduledActivityName];
|
|
var hasScheduledActivity = context.HasScheduledActivity(expectedActivity);
|
|
Assert.True(hasScheduledActivity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Schedule_FailedToConnect_Activity_On_HttpRequestException()
|
|
{
|
|
// Arrange
|
|
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers(
|
|
failedToConnect: "mockFailedToConnect"
|
|
);
|
|
|
|
var responseHandler = CreateExceptionHandler<HttpRequestException>("Connection failed");
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert
|
|
var expectedScheduledActivity = childActivities["mockFailedToConnect"];
|
|
var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity);
|
|
Assert.True(hasScheduledExpectedActivity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Schedule_Timeout_Activity_On_TaskCanceledException()
|
|
{
|
|
// Arrange
|
|
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers(
|
|
timeout: "mockTimeout"
|
|
);
|
|
|
|
var responseHandler = CreateExceptionHandler<TaskCanceledException>("Request timed out");
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert
|
|
var expectedScheduledActivity = childActivities["mockTimeout"];
|
|
var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity);
|
|
Assert.True(hasScheduledExpectedActivity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Schedule_No_Activity_When_No_Status_Code_Cases_Match_And_No_Unmatched_Handler()
|
|
{
|
|
// Arrange
|
|
var (configured, _) = CreateSendHttpRequestWithStatusHandlers([(200, "handler200")], unmatchedHandler: null);
|
|
var responseHandler = CreateResponseHandler(HttpStatusCode.InternalServerError); // 500 - no match
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(configured, responseHandler);
|
|
|
|
// Assert
|
|
var allScheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
|
|
Assert.Empty(allScheduledActivities);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Set_Response_Headers_Output()
|
|
{
|
|
// Arrange
|
|
var expectedHeaders = new Dictionary<string, string>
|
|
{
|
|
{ "Custom-Header", "CustomValue" },
|
|
{ "X-Rate-Limit", "100" }
|
|
};
|
|
|
|
var responseHandler = CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders);
|
|
var sendHttpRequest = CreateSendHttpRequest(new("https://api.example.com/headers"));
|
|
|
|
// Act
|
|
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
|
|
|
|
// Assert
|
|
var responseHeadersObj = context.GetActivityOutput(() => sendHttpRequest.ResponseHeaders);
|
|
var responseHeaders = responseHeadersObj as HttpHeaders;
|
|
Assert.NotNull(responseHeaders);
|
|
Assert.True(responseHeaders.ContainsKey("Custom-Header"));
|
|
Assert.True(responseHeaders.ContainsKey("X-Rate-Limit"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Have_Correct_Activity_Attributes()
|
|
{
|
|
var fixture = new ActivityTestFixture(new SendHttpRequest());
|
|
fixture.AssertActivityAttributes(
|
|
expectedNamespace: "Elsa",
|
|
expectedCategory: "HTTP",
|
|
expectedDisplayName: "HTTP Request",
|
|
expectedDescription: "Send an HTTP request.",
|
|
expectedKind: Elsa.Workflows.ActivityKind.Task
|
|
);
|
|
}
|
|
|
|
// Private helper methods placed after all public members
|
|
private static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateResponseHandler(
|
|
HttpStatusCode statusCode,
|
|
string? content = null,
|
|
RequestCapture? requestCapture = null,
|
|
Dictionary<string, string>? additionalHeaders = null)
|
|
{
|
|
return (request, _) =>
|
|
{
|
|
if (requestCapture != null)
|
|
requestCapture.CapturedRequest = request;
|
|
|
|
return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders));
|
|
};
|
|
}
|
|
|
|
private sealed class RequestCapture
|
|
{
|
|
public HttpRequestMessage? CapturedRequest { get; set; }
|
|
}
|
|
|
|
private static SendHttpRequest CreateSendHttpRequest(
|
|
Uri url,
|
|
string method = "GET",
|
|
object? content = null,
|
|
string? contentType = null,
|
|
string? authorization = null)
|
|
{
|
|
return new()
|
|
{
|
|
Url = new(url),
|
|
Method = new(method),
|
|
Content = content != null ? new Input<object?>(content) : null!,
|
|
ContentType = contentType != null ? new Input<string?>(contentType) : null!,
|
|
Authorization = authorization != null ? new Input<string?>(authorization) : null!,
|
|
ExpectedStatusCodes = new List<HttpStatusCodeCase>()
|
|
};
|
|
}
|
|
|
|
private static Task<ActivityExecutionContext> ExecuteActivityAsync(
|
|
SendHttpRequest sendHttpRequest,
|
|
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseHandler)
|
|
{
|
|
return new ActivityTestFixture(sendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync();
|
|
}
|
|
|
|
private static (SendHttpRequest sendHttpRequest, Dictionary<string, IActivity> childActivities) CreateSendHttpRequestWithStatusHandlers(
|
|
(int statusCode, string activityName)[] expectedStatusCodes,
|
|
string? unmatchedHandler)
|
|
{
|
|
var childActivities = new Dictionary<string, IActivity>();
|
|
|
|
// Create mock activities for expected status codes
|
|
var expectedStatusCodeCases = expectedStatusCodes.Select(x =>
|
|
{
|
|
var mockActivity = Substitute.For<IActivity>();
|
|
childActivities[x.activityName] = mockActivity;
|
|
return new HttpStatusCodeCase(x.statusCode, mockActivity);
|
|
}).ToList();
|
|
|
|
// Create mock activity for unmatched handler
|
|
var unmatchedActivity = Substitute.For<IActivity>();
|
|
if (unmatchedHandler is not null)
|
|
{
|
|
childActivities[unmatchedHandler] = unmatchedActivity;
|
|
}
|
|
|
|
var sendHttpRequest = new SendHttpRequest
|
|
{
|
|
Url = new(new Uri("https://api.example.com/test")),
|
|
Method = new("GET"),
|
|
ExpectedStatusCodes = expectedStatusCodeCases,
|
|
UnmatchedStatusCode = unmatchedHandler is not null ? unmatchedActivity : null
|
|
};
|
|
|
|
return (sendHttpRequest, childActivities);
|
|
}
|
|
|
|
private static (SendHttpRequest sendHttpRequest, Dictionary<string, IActivity> childActivities) CreateSendHttpRequestWithErrorHandlers(
|
|
string? failedToConnect = null,
|
|
string? timeout = null)
|
|
{
|
|
var childActivities = new Dictionary<string, IActivity>();
|
|
|
|
IActivity? failedToConnectActivity = null;
|
|
IActivity? timeoutActivity = null;
|
|
|
|
if (failedToConnect != null)
|
|
{
|
|
failedToConnectActivity = Substitute.For<IActivity>();
|
|
childActivities[failedToConnect] = failedToConnectActivity;
|
|
}
|
|
|
|
if (timeout != null)
|
|
{
|
|
timeoutActivity = Substitute.For<IActivity>();
|
|
childActivities[timeout] = timeoutActivity;
|
|
}
|
|
|
|
var sendHttpRequest = new SendHttpRequest
|
|
{
|
|
Url = new(new Uri("https://api.example.com/error")),
|
|
Method = new("GET"),
|
|
ExpectedStatusCodes = new List<HttpStatusCodeCase>(),
|
|
FailedToConnect = failedToConnectActivity,
|
|
Timeout = timeoutActivity
|
|
};
|
|
|
|
return (sendHttpRequest, childActivities);
|
|
}
|
|
|
|
private static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateExceptionHandler<TException>(string message)
|
|
where TException : Exception
|
|
{
|
|
return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!);
|
|
}
|
|
}
|