w4c-workflows-api/w4c-workflows-api.Tests/RestConnectorExecutorTests.cs
2026-09-12 01:02:46 +03:00

258 lines
10 KiB
C#

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.Nodes;
using w4c_workflows.Services.Nodes.Connectors;
using Xunit;
namespace w4c_workflows.Tests;
/// <summary>
/// Unit tests for the declarative REST connector executor, driven through a stub
/// <see cref="HttpMessageHandler"/> so no real network is touched.
/// </summary>
public class RestConnectorExecutorTests
{
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; }
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
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 NodeBlueprint TelegramBlueprint() => new()
{
Type = "test.telegram",
DisplayName = "Telegram",
Origin = NodeOrigin.Connector,
Credentials = new List<NodeCredentialLink> { new() { Alias = "telegram", Required = true } },
Connector = new NodeConnector
{
BaseUrl = "https://api.telegram.org",
Path = "/bot{credential.token}/{operation}",
Method = "POST",
ContentType = "json",
CredentialAlias = "telegram",
ResponsePath = "result",
},
};
private static RestConnectorExecutor Executor(StubHandler handler, NodeBlueprint? blueprint = null)
=> new(
blueprint ?? TelegramBlueprint(),
new StubFactory(handler),
EgressTestData.Guard(),
EgressTestData.Quota());
private static NodeExecutionContext Context(
JsonObject parameters,
IReadOnlyDictionary<string, CredentialData>? credentials = null,
IDictionary<string, object?>? state = null,
NodeBlueprint? blueprint = null)
=> new()
{
Blueprint = blueprint ?? TelegramBlueprint(),
Parameters = parameters,
Inputs = new IReadOnlyList<FlowItem>[] { Array.Empty<FlowItem>() },
Credentials = credentials ?? TelegramCredential(),
State = state ?? new Dictionary<string, object?>(),
};
private static IReadOnlyDictionary<string, CredentialData> TelegramCredential()
=> new Dictionary<string, CredentialData>
{
["telegram"] = new("telegramApi", new JsonObject { ["token"] = "123:ABC" }),
};
private static JsonObject TelegramParameters(JsonObject? body = null, JsonObject? options = null)
=> new()
{
["operation"] = "sendMessage",
["body"] = body ?? new JsonObject { ["chat_id"] = 42, ["text"] = "hi" },
["options"] = options ?? new JsonObject(),
};
[Fact]
public async Task Builds_the_url_from_parameter_and_credential_placeholders()
{
var handler = new StubHandler(_ => Json("""{"ok":true,"result":{"message_id":5}}"""));
var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal("/bot123:ABC/sendMessage", handler.LastRequest!.RequestUri!.AbsolutePath);
Assert.Equal(HttpMethod.Post, handler.LastRequest.Method);
Assert.Equal("application/json", handler.LastRequest.Content!.Headers.ContentType!.MediaType);
Assert.Contains("\"chat_id\":42", handler.LastBody);
}
[Fact]
public async Task Response_path_extracts_the_payload()
{
var handler = new StubHandler(_ => Json("""{"ok":true,"result":{"message_id":5}}"""));
var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default);
var item = Assert.Single(outcome.Outputs[0]);
Assert.Equal(5, item.Json["message_id"]!.GetValue<int>());
}
[Fact]
public async Task Response_path_array_becomes_one_item_per_element()
{
var handler = new StubHandler(_ => Json("""{"ok":true,"result":[{"n":1},{"n":2}]}"""));
var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default);
Assert.Equal(2, outcome.Outputs[0].Count);
Assert.Equal(2, outcome.Outputs[0][1].Json["n"]!.GetValue<int>());
}
[Fact]
public async Task Non_json_response_becomes_a_text_item()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("pong", Encoding.UTF8, "text/plain"),
});
var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default);
Assert.Equal("pong", Assert.Single(outcome.Outputs[0]).Json["data"]!.GetValue<string>());
}
[Fact]
public async Task Unresolved_placeholder_fails_before_sending()
{
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler).RunAsync(
Context(TelegramParameters(), credentials: new Dictionary<string, CredentialData>()), default);
Assert.False(outcome.Succeeded);
Assert.Equal("invalid_connector_url", outcome.Failure!.Code);
Assert.Contains("{credential.token}", outcome.Failure.Message);
Assert.Equal(0, handler.Calls);
}
[Fact]
public async Task Blocked_host_is_rejected_before_sending()
{
var handler = new StubHandler(_ => Json("{}"));
var guard = EgressTestData.Guard(EgressTestData.Policy(o => o.BlockedHosts = ["api.telegram.org"]));
var executor = new RestConnectorExecutor(
TelegramBlueprint(), new StubFactory(handler), guard, EgressTestData.Quota());
var outcome = await executor.RunAsync(Context(TelegramParameters()), default);
Assert.False(outcome.Succeeded);
Assert.Equal("egress_blocked", outcome.Failure!.Code);
Assert.Equal(0, handler.Calls);
}
[Fact]
public async Task Quota_is_shared_across_calls()
{
var handler = new StubHandler(_ => Json("""{"ok":true,"result":{}}"""));
var quota = EgressTestData.Quota(o => o.MaxRequestsPerRun = 1);
var executor = new RestConnectorExecutor(
TelegramBlueprint(), new StubFactory(handler), EgressTestData.Guard(), quota);
var state = new Dictionary<string, object?>();
var first = await executor.RunAsync(Context(TelegramParameters(), state: state), default);
var second = await executor.RunAsync(Context(TelegramParameters(), state: state), default);
Assert.True(first.Succeeded, first.Failure?.Message);
Assert.False(second.Succeeded);
Assert.Equal("quota_exceeded", second.Failure!.Code);
}
[Fact]
public async Task Error_status_fails_unless_never_error()
{
var handler = new StubHandler(_ => Json("""{"ok":false,"description":"bad"}""", HttpStatusCode.BadRequest));
// No response path, so a tolerated error body maps straight to an item.
var plain = new NodeBlueprint
{
Type = "test.plain",
DisplayName = "Plain",
Origin = NodeOrigin.Connector,
Connector = new NodeConnector
{
BaseUrl = "https://api.telegram.org",
Path = "/bot{credential.token}/{operation}",
Method = "POST",
ContentType = "json",
CredentialAlias = "telegram",
},
};
var failing = await Executor(handler).RunAsync(Context(TelegramParameters()), default);
var tolerated = await Executor(handler, plain).RunAsync(
Context(
TelegramParameters(options: new JsonObject { ["neverError"] = true }),
blueprint: plain),
default);
Assert.False(failing.Succeeded);
Assert.Equal("http_error", failing.Failure!.Code);
Assert.Equal(400, failing.Failure.HttpStatus);
Assert.True(tolerated.Succeeded, tolerated.Failure?.Message);
Assert.Equal("bad", Assert.Single(tolerated.Outputs[0]).Json["description"]!.GetValue<string>());
}
[Fact]
public async Task Form_content_type_sends_form_encoded_fields()
{
var blueprint = new NodeBlueprint
{
Type = "test.form",
DisplayName = "Form",
Origin = NodeOrigin.Connector,
Connector = new NodeConnector
{
BaseUrl = "https://api.example.com",
Path = "/token",
Method = "POST",
ContentType = "form",
BodyParameter = "body",
},
};
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, blueprint).RunAsync(
Context(new JsonObject { ["body"] = new JsonObject { ["grant_type"] = "client_credentials" } },
blueprint: blueprint), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal("application/x-www-form-urlencoded", handler.LastRequest!.Content!.Headers.ContentType!.MediaType);
Assert.Equal("grant_type=client_credentials", handler.LastBody);
}
}