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); } }