w4c-workflows-api/w4c-workflows-api.Tests/HttpRequestNodeExecutorTests.cs

237 lines
8.6 KiB
C#
Raw Normal View History

2026-09-11 22:02:46 +00:00
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Credentials;
using w4c_workflows.Services.Nodes;
using w4c_workflows.Services.Nodes.Executors;
using w4c_workflows.Services.Security;
using Xunit;
namespace w4c_workflows.Tests;
/// <summary>
/// Unit tests for the HTTP Request node, driven through a stub
/// <see cref="HttpMessageHandler"/> so no real network is touched.
/// </summary>
public class HttpRequestNodeExecutorTests
{
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
if (request.Content != null)
LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
return _responder(request);
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK)
=> new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static FlowItem Item(string json) => FlowItem.FromJson(JsonNode.Parse(json)!.AsObject());
private static NodeExecutionContext Context(
JsonObject parameters,
IReadOnlyList<FlowItem>? input = null,
IReadOnlyDictionary<string, CredentialData>? credentials = null)
=> new()
{
Blueprint = Catalog.Get("core.httpRequest")!,
Parameters = parameters,
Inputs = new IReadOnlyList<FlowItem>[] { input ?? Array.Empty<FlowItem>() },
Credentials = credentials ?? new Dictionary<string, CredentialData>(),
};
private static HttpRequestNodeExecutor Executor(StubHandler handler)
=> new(
new StubFactory(handler),
new CredentialTypeCatalog(),
EgressTestData.Guard(),
EgressTestData.Quota(),
BinaryTestData.Store());
[Fact]
public async Task Get_json_object_returns_one_item()
{
var handler = new StubHandler(_ => Json("""{"a":1}"""));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(1, Assert.Single(outcome.Outputs[0]).Json["a"]!.GetValue<int>());
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
}
[Fact]
public async Task Get_json_array_returns_one_item_per_element()
{
var handler = new StubHandler(_ => Json("""[{"n":1},{"n":2}]"""));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.Equal(2, outcome.Outputs[0].Count);
Assert.Equal(2, outcome.Outputs[0][1].Json["n"]!.GetValue<int>());
}
[Fact]
public async Task Non_success_status_fails_with_the_http_status()
{
var handler = new StubHandler(_ => Json("""{"message":"nope"}""", HttpStatusCode.NotFound));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.False(outcome.Succeeded);
Assert.Equal("http_error", outcome.Failure!.Code);
Assert.Equal(404, outcome.Failure.HttpStatus);
}
[Fact]
public async Task Never_error_returns_the_body_instead_of_failing()
{
var handler = new StubHandler(_ => Json("""{"message":"nope"}""", HttpStatusCode.BadRequest));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["neverError"] = true },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal("nope", Assert.Single(outcome.Outputs[0]).Json["message"]!.GetValue<string>());
}
[Fact]
public async Task Post_sends_headers_and_a_json_body()
{
var handler = new StubHandler(_ => Json("""{"ok":true}"""));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/x",
["sendHeaders"] = true,
["headers"] = new JsonArray(
new JsonObject { ["name"] = "X-Trace", ["value"] = "abc" }),
["sendBody"] = true,
["bodyContentType"] = "json",
["body"] = new JsonObject { ["name"] = "Ada" },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.Equal("abc", handler.LastRequest.Headers.GetValues("X-Trace").Single());
Assert.Contains("\"name\":\"Ada\"", handler.LastBody);
}
[Fact]
public async Task Invalid_url_fails_before_sending()
{
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject { ["url"] = "not a url" }), default);
Assert.False(outcome.Succeeded);
Assert.Equal("invalid_url", outcome.Failure!.Code);
Assert.Null(handler.LastRequest);
}
[Fact]
public async Task Text_response_format_wraps_the_body_in_data()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
});
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["responseFormat"] = "text" },
}), default);
Assert.Equal("hello", Assert.Single(outcome.Outputs[0]).Json["data"]!.GetValue<string>());
}
[Fact]
public async Task Put_output_in_field_wraps_the_response()
{
var handler = new StubHandler(_ => Json("""{"a":1}"""));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["putOutputInField"] = "response" },
}), default);
var item = Assert.Single(outcome.Outputs[0]);
Assert.Equal(1, item.Json["response"]!["a"]!.GetValue<int>());
}
[Fact]
public async Task Credential_auth_applies_basic_authorization()
{
var handler = new StubHandler(_ => Json("{}"));
var credentials = new Dictionary<string, CredentialData>
{
[HttpRequestNodeExecutor.CredentialAlias] = new(
"httpBasicAuth",
new JsonObject { ["username"] = "ada", ["password"] = "secret" }),
};
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["authentication"] = "credential",
}, credentials: credentials), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ada:secret"));
Assert.Equal(expected, handler.LastRequest!.Headers.Authorization!.ToString());
}
[Fact]
public async Task Missing_required_credential_fails()
{
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["authentication"] = "credential",
}), default);
Assert.False(outcome.Succeeded);
Assert.Equal("missing_credential", outcome.Failure!.Code);
}
}