330 lines
11 KiB
C#
330 lines
11 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.Credentials;
|
|
using w4c_workflows.Services.Nodes;
|
|
using w4c_workflows.Services.Nodes.Executors;
|
|
using Xunit;
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
/// <summary>
|
|
/// Pagination tests for the HTTP Request node. The stub handler answers per call
|
|
/// index, so a multi-page flow is fully deterministic and network-free.
|
|
/// </summary>
|
|
public class HttpRequestNodeExecutorPaginationTests
|
|
{
|
|
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
|
|
|
|
private sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, int, HttpResponseMessage> _responder;
|
|
private int _count;
|
|
|
|
public StubHandler(Func<HttpRequestMessage, int, HttpResponseMessage> responder)
|
|
=> _responder = responder;
|
|
|
|
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
|
=> _responder = (request, _) => responder(request);
|
|
|
|
public List<HttpRequestMessage> Requests { get; } = new();
|
|
public List<string?> Bodies { get; } = new();
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add(request);
|
|
Bodies.Add(request.Content == null
|
|
? null
|
|
: await request.Content.ReadAsStringAsync(cancellationToken));
|
|
return _responder(request, _count++);
|
|
}
|
|
}
|
|
|
|
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 HttpRequestNodeExecutor Executor(StubHandler handler)
|
|
=> new(
|
|
new StubFactory(handler),
|
|
new CredentialTypeCatalog(),
|
|
EgressTestData.Guard(),
|
|
EgressTestData.Quota(),
|
|
BinaryTestData.Store());
|
|
|
|
private static JsonObject Paginated(JsonObject pagination, string outputPath = "data")
|
|
=> new()
|
|
{
|
|
["url"] = "https://example.com/x",
|
|
["options"] = new JsonObject
|
|
{
|
|
["pagination"] = pagination,
|
|
["outputPath"] = outputPath,
|
|
},
|
|
};
|
|
|
|
private static NodeExecutionContext Context(JsonObject parameters) => new()
|
|
{
|
|
Blueprint = Catalog.Get("core.httpRequest")!,
|
|
Parameters = parameters,
|
|
Inputs = new IReadOnlyList<FlowItem>[] { Array.Empty<FlowItem>() },
|
|
};
|
|
|
|
private static async Task<NodeExecutionOutcome> Run(StubHandler handler, JsonObject parameters)
|
|
=> await Executor(handler).RunAsync(Context(parameters), default);
|
|
|
|
private static List<string> Names(NodeExecutionOutcome outcome)
|
|
=> outcome.Outputs[0].Select(item => item.Json["n"]!.GetValue<int>().ToString()).ToList();
|
|
|
|
// ---------------------------------------------------------------- nextUrl
|
|
|
|
[Fact]
|
|
public async Task NextUrl_mode_follows_the_response_url_and_merges_pages()
|
|
{
|
|
var handler = new StubHandler((_, call) => call == 0
|
|
? Json("""{"data":[{"n":1}],"next":"https://example.com/x?page=2"}""")
|
|
: Json("""{"data":[{"n":2}]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "nextUrl",
|
|
["nextUrlPath"] = "next",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
Assert.Contains("page=2", handler.Requests[1].RequestUri!.Query);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NextUrl_mode_resolves_a_relative_url()
|
|
{
|
|
var handler = new StubHandler((_, call) => call == 0
|
|
? Json("""{"data":[{"n":1}],"next":"/x?page=2"}""")
|
|
: Json("""{"data":[{"n":2}]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "nextUrl",
|
|
["nextUrlPath"] = "next",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Equal("https://example.com/x?page=2", handler.Requests[1].RequestUri!.AbsoluteUri);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- cursor
|
|
|
|
[Fact]
|
|
public async Task Cursor_mode_feeds_the_cursor_into_a_query_parameter()
|
|
{
|
|
var handler = new StubHandler((_, call) => call switch
|
|
{
|
|
0 => Json("""{"data":[{"n":1}],"nextCursor":"abc"}"""),
|
|
1 => Json("""{"data":[{"n":2}],"nextCursor":""}"""),
|
|
_ => Json("""{"data":[]}"""),
|
|
});
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "cursor",
|
|
["cursorPath"] = "nextCursor",
|
|
["cursorName"] = "cursor",
|
|
["cursorPlace"] = "query",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
Assert.Contains("cursor=abc", handler.Requests[1].RequestUri!.Query);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cursor_mode_can_place_the_cursor_in_a_header()
|
|
{
|
|
var handler = new StubHandler((_, call) => call == 0
|
|
? Json("""{"data":[{"n":1}],"nextCursor":"abc"}""")
|
|
: Json("""{"data":[{"n":2}]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "cursor",
|
|
["cursorPath"] = "nextCursor",
|
|
["cursorName"] = "X-Cursor",
|
|
["cursorPlace"] = "header",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Equal("abc", handler.Requests[1].Headers.GetValues("X-Cursor").Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cursor_mode_can_place_the_cursor_in_the_body()
|
|
{
|
|
var handler = new StubHandler((_, call) => call == 0
|
|
? Json("""{"data":[{"n":1}],"nextCursor":"abc"}""")
|
|
: Json("""{"data":[{"n":2}]}"""));
|
|
|
|
var parameters = Paginated(new JsonObject
|
|
{
|
|
["mode"] = "cursor",
|
|
["cursorPath"] = "nextCursor",
|
|
["cursorName"] = "cursor",
|
|
["cursorPlace"] = "body",
|
|
});
|
|
parameters["sendBody"] = true;
|
|
parameters["bodyContentType"] = "json";
|
|
parameters["body"] = new JsonObject { ["query"] = "all" };
|
|
|
|
var outcome = await Run(handler, parameters);
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Contains("\"cursor\":\"abc\"", handler.Bodies[1]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- pageNumber
|
|
|
|
[Fact]
|
|
public async Task PageNumber_mode_increments_and_stops_on_an_empty_page()
|
|
{
|
|
var handler = new StubHandler((_, call) => call < 2
|
|
? Json($$"""{"data":[{"n":{{call + 1}}}]}""")
|
|
: Json("""{"data":[]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
["pageStart"] = 1,
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1", "2" }, Names(outcome));
|
|
Assert.Equal(3, handler.Requests.Count);
|
|
Assert.Contains("page=1", handler.Requests[0].RequestUri!.Query);
|
|
Assert.Contains("page=3", handler.Requests[2].RequestUri!.Query);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_first_page_stops_immediately()
|
|
{
|
|
var handler = new StubHandler(_ => Json("""{"data":[]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Empty(outcome.Outputs[0]);
|
|
Assert.Single(handler.Requests);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- guards
|
|
|
|
[Fact]
|
|
public async Task Max_pages_caps_the_number_of_requests()
|
|
{
|
|
var handler = new StubHandler(_ => Json("""{"data":[{"n":1}]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
["maxPages"] = 2,
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
Assert.Equal(2, outcome.Outputs[0].Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Max_items_truncates_the_merged_output()
|
|
{
|
|
var handler = new StubHandler(_ => Json("""{"data":[{"n":1},{"n":2}]}"""));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
["maxItems"] = 3,
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(3, outcome.Outputs[0].Count);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stop_status_code_ends_pagination_without_adding_the_page()
|
|
{
|
|
var handler = new StubHandler((_, call) => call == 0
|
|
? Json("""{"data":[{"n":1}]}""")
|
|
: new HttpResponseMessage(HttpStatusCode.NoContent));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
["stopStatusCodes"] = "204",
|
|
}));
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal(new[] { "1" }, Names(outcome));
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_pagination_configuration_fails_with_a_clear_code()
|
|
{
|
|
var handler = new StubHandler(_ => Json("{}"));
|
|
|
|
var outcome = await Run(handler, Paginated(new JsonObject
|
|
{
|
|
["mode"] = "cursor",
|
|
// cursorPath / cursorName deliberately missing
|
|
}));
|
|
|
|
Assert.False(outcome.Succeeded);
|
|
Assert.Equal("invalid_pagination", outcome.Failure!.Code);
|
|
Assert.Empty(handler.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Non_json_response_formats_ignore_pagination()
|
|
{
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
|
|
});
|
|
|
|
var parameters = Paginated(new JsonObject
|
|
{
|
|
["mode"] = "pageNumber",
|
|
["pageName"] = "page",
|
|
});
|
|
((JsonObject)parameters["options"]!)["responseFormat"] = "text";
|
|
|
|
var outcome = await Run(handler, parameters);
|
|
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
|
Assert.Equal("hello", Assert.Single(outcome.Outputs[0]).Json["data"]!.GetValue<string>());
|
|
Assert.Single(handler.Requests);
|
|
}
|
|
}
|