using System.Net; using System.Net.Http.Headers; 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.Binary; using w4c_workflows.Services.Nodes.Executors; using Xunit; namespace w4c_workflows.Tests; /// /// Binary/file tests for the HTTP Request node: responseFormat: file /// downloads into the binary store, and multipart/binary bodies /// upload an input item's binary attachment. The stub handler keeps it /// network-free. /// public class HttpRequestNodeExecutorBinaryTests { private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog(); private sealed class StubHandler : HttpMessageHandler { private readonly Func _responder; public StubHandler(Func responder) => _responder = responder; public HttpRequestMessage? LastRequest { get; private set; } public byte[]? LastBytes { get; private set; } public string? LastBody { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; if (request.Content != null) { LastBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken); LastBody = Encoding.UTF8.GetString(LastBytes); } 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 HttpResponseMessage Binary( byte[] payload, string? contentType = null, string? fileName = null) { var content = new ByteArrayContent(payload); if (contentType != null) content.Headers.ContentType = new MediaTypeHeaderValue(contentType); if (fileName != null) content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileNameStar = fileName, }; return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; } private static HttpRequestNodeExecutor Executor(StubHandler handler, IBinaryStore store) => new( new StubFactory(handler), new CredentialTypeCatalog(), EgressTestData.Guard(), EgressTestData.Quota(), store); private static NodeExecutionContext Context(JsonObject parameters, IReadOnlyList? input = null) => new() { Blueprint = Catalog.Get("core.httpRequest")!, Parameters = parameters, Inputs = new IReadOnlyList[] { input ?? Array.Empty() }, Credentials = new Dictionary(), }; private static async Task ItemWithBinaryAsync( IBinaryStore store, byte[] bytes, string property = "data", string? fileName = "upload.bin", string? mime = "application/octet-stream") { var attachment = await store.SaveAsync(new MemoryStream(bytes), fileName, mime); return new FlowItem { Json = new JsonObject(), Binary = new Dictionary(StringComparer.Ordinal) { [property] = attachment }, }; } private static async Task ReadAllAsync(Stream stream) { using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer); return buffer.ToArray(); } private static JsonObject FileOptions(string? putOutputInField = null) { var options = new JsonObject { ["responseFormat"] = "file" }; if (putOutputInField != null) options["putOutputInField"] = putOutputInField; return options; } // ------------------------------------------------------------------ download [Fact] public async Task File_response_format_stores_the_body_and_emits_a_binary_item() { var store = BinaryTestData.Store(); var payload = Encoding.UTF8.GetBytes("%PDF-1.7 fake report"); var handler = new StubHandler(_ => Binary(payload, "application/pdf", "report.pdf")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/files/ignored", ["options"] = FileOptions(), }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal("report.pdf", item.Json["fileName"]!.GetValue()); Assert.Equal("application/pdf", item.Json["mimeType"]!.GetValue()); Assert.Equal(payload.Length, item.Json["sizeBytes"]!.GetValue()); Assert.Equal("pdf", item.Json["fileExtension"]!.GetValue()); var attachment = Assert.Single(item.Binary!).Value; Assert.Equal("report.pdf", attachment.FileName); await using var stored = await store.OpenAsync(attachment.AssetId); Assert.Equal(payload, await ReadAllAsync(stored!)); } [Fact] public async Task File_response_falls_back_to_the_url_segment_for_the_name() { var store = BinaryTestData.Store(); var payload = Encoding.UTF8.GetBytes("data"); var handler = new StubHandler(_ => Binary(payload, "application/octet-stream")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/files/report.dat", ["options"] = FileOptions(), }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("report.dat", Assert.Single(outcome.Outputs[0]).Json["fileName"]!.GetValue()); } [Fact] public async Task File_response_honours_put_output_in_field() { var store = BinaryTestData.Store(); var handler = new StubHandler(_ => Binary(Encoding.UTF8.GetBytes("x"), "text/plain", "x.txt")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = FileOptions("file"), }), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal("x.txt", item.Json["file"]!["fileName"]!.GetValue()); Assert.NotNull(item.Binary!["data"]); } [Fact] public async Task File_response_is_not_paginated() { var store = BinaryTestData.Store(); var calls = 0; var handler = new StubHandler(_ => { calls++; return Binary(Encoding.UTF8.GetBytes("once"), "application/octet-stream", "once.bin"); }); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["responseFormat"] = "file", ["pagination"] = new JsonObject { ["mode"] = "nextUrl", ["nextUrlPath"] = "next" }, }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(1, calls); Assert.Single(outcome.Outputs[0]); } // ------------------------------------------------------------------ upload [Fact] public async Task Multipart_body_sends_fields_and_the_input_binary() { var store = BinaryTestData.Store(); var fileBytes = Encoding.UTF8.GetBytes("FILE-CONTENT"); var input = await ItemWithBinaryAsync(store, fileBytes, "data", "note.txt", "text/plain"); var handler = new StubHandler(_ => Json("""{"ok":true}""")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/upload", ["sendBody"] = true, ["bodyContentType"] = "multipart", ["binaryField"] = "data", ["body"] = new JsonObject { ["caption"] = "hi" }, }, new[] { input }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.StartsWith("multipart/form-data", handler.LastRequest!.Content!.Headers.ContentType!.ToString()); Assert.Contains("caption", handler.LastBody); Assert.Contains("hi", handler.LastBody); Assert.Contains("note.txt", handler.LastBody); Assert.Contains("FILE-CONTENT", handler.LastBody); } [Fact] public async Task Multipart_with_an_empty_binary_field_sends_fields_only() { var store = BinaryTestData.Store(); var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/upload", ["sendBody"] = true, ["bodyContentType"] = "multipart", ["binaryField"] = "", ["body"] = new JsonObject { ["caption"] = "hi" }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Contains("caption", handler.LastBody); Assert.DoesNotContain("filename=", handler.LastBody); } [Fact] public async Task Binary_body_sends_the_raw_attachment_with_its_mime_type() { var store = BinaryTestData.Store(); var fileBytes = Encoding.UTF8.GetBytes("raw-bytes"); var input = await ItemWithBinaryAsync(store, fileBytes, "data", "raw.bin", "application/x-custom"); var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "PUT", ["url"] = "https://example.com/blob", ["sendBody"] = true, ["bodyContentType"] = "binary", ["binaryField"] = "data", }, new[] { input }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("application/x-custom", handler.LastRequest!.Content!.Headers.ContentType!.MediaType); Assert.Equal(fileBytes, handler.LastBytes); } [Fact] public async Task Binary_body_defaults_to_octet_stream_when_the_attachment_has_no_mime() { var store = BinaryTestData.Store(); var input = await ItemWithBinaryAsync(store, [1, 2, 3], "data", null, null); var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/blob", ["sendBody"] = true, ["bodyContentType"] = "binary", ["binaryField"] = "data", }, new[] { input }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal( "application/octet-stream", handler.LastRequest!.Content!.Headers.ContentType!.MediaType); } [Fact] public async Task Binary_body_without_the_named_field_fails_before_sending() { var store = BinaryTestData.Store(); var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/blob", ["sendBody"] = true, ["bodyContentType"] = "binary", ["binaryField"] = "data", }), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_parameter", outcome.Failure!.Code); Assert.Contains("no binary field 'data'", outcome.Failure.Message); } [Fact] public async Task Multipart_without_the_named_field_fails() { var store = BinaryTestData.Store(); var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/upload", ["sendBody"] = true, ["bodyContentType"] = "multipart", ["binaryField"] = "data", ["body"] = new JsonObject { ["caption"] = "hi" }, }), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_parameter", outcome.Failure!.Code); } [Fact] public async Task Unknown_binary_asset_reference_fails() { var store = BinaryTestData.Store(); var input = new FlowItem { Json = new JsonObject(), Binary = new Dictionary(StringComparer.Ordinal) { ["data"] = new BinaryAttachment("sha256:" + new string('b', 64), "gone.bin", "application/octet-stream", 1), }, }; var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, store).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/blob", ["sendBody"] = true, ["bodyContentType"] = "binary", ["binaryField"] = "data", }, new[] { input }), default); Assert.False(outcome.Succeeded); Assert.Contains("was not found in the binary store", outcome.Failure!.Message); } }