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;
///
/// 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.
///
public class HttpRequestNodeExecutorPaginationTests
{
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func _responder;
private int _count;
public StubHandler(Func responder)
=> _responder = responder;
public StubHandler(Func responder)
=> _responder = (request, _) => responder(request);
public List Requests { get; } = new();
public List Bodies { get; } = new();
protected override async Task 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[] { Array.Empty() },
};
private static async Task Run(StubHandler handler, JsonObject parameters)
=> await Executor(handler).RunAsync(Context(parameters), default);
private static List Names(NodeExecutionOutcome outcome)
=> outcome.Outputs[0].Select(item => item.Json["n"]!.GetValue().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);
}
// P1-13: a non-positive guard means "use the default", never "unlimited" —
// otherwise a self-referential nextUrl loops until quota/timeout.
[Theory]
[InlineData(0)]
[InlineData(-5)]
public async Task Non_positive_max_pages_falls_back_to_the_default_cap(int configured)
{
// nextUrl is always present, so only the (defaulted) page cap can stop it.
var handler = new StubHandler(_ =>
Json("""{"data":[{"n":1}],"next":"https://example.com/x?page=2"}"""));
var outcome = await Run(handler, Paginated(new JsonObject
{
["mode"] = "nextUrl",
["nextUrlPath"] = "next",
["maxPages"] = configured,
}));
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(50, handler.Requests.Count);
Assert.Equal(50, outcome.Outputs[0].Count);
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public async Task Non_positive_max_items_falls_back_to_the_default_cap(int configured)
{
// 30 items per page: the default item cap (1000) is reached on page 34.
var items = string.Join(",", Enumerable.Range(0, 30).Select(i => $"{{\"n\":{i}}}"));
var handler = new StubHandler(_ => Json($$"""{"data":[{{items}}]}"""));
var outcome = await Run(handler, Paginated(new JsonObject
{
["mode"] = "pageNumber",
["pageName"] = "page",
["maxItems"] = configured,
}));
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(1000, outcome.Outputs[0].Count);
Assert.Equal(34, 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());
Assert.Single(handler.Requests);
}
}